Download the PHP package vadimermolenko8787/scanner-trap without Composer
On this page you can find all versions of the php package vadimermolenko8787/scanner-trap. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.
Download vadimermolenko8787/scanner-trap
More information about vadimermolenko8787/scanner-trap
Files in vadimermolenko8787/scanner-trap
Package scanner-trap
Short Description Blacklists vulnerability scanners on their first probe of a decoy path such as /.env. File, Redis or APCu storage, optional central database for several servers.
License MIT
Homepage https://github.com/vadimermolenko8787/scanner-trap
Informations about the package scanner-trap
scanner-trap
Every public PHP site gets probed for /.env, /.git/config, /phpmyadmin and ?id=1 UNION SELECT many times a
day. No real visitor ever asks for those. scanner-trap treats such a request as a confession: the address that sent
it is blacklisted, and from then on it gets 403 Forbidden on every page, so the scanner never reaches the rest of
your site.
It is a small Composer package with no framework requirement. One call at the top of public/index.php is enough
on a single server, and the same package scales to a fleet behind a load balancer.
Contents
- How it works
- Quick start
- Going live
- Choosing a setup
- Web server routing
- What gets trapped
- Scanners that change address
- Imported blocklists
- Command line
- Configuration reference
- Things to know
- Troubleshooting
How it works
- The package keeps a list of decoys: paths, query fragments and scanner User-Agents that a person browsing your site would never produce.
- When a request matches a decoy, the client's address is written to a blacklist with an expiry, seven days by default.
- Every request first asks the blacklist, in a single round trip to the store. A blacklisted address gets
403. - When several addresses of one network get caught, the whole network is blocked, because scanners like to rotate addresses.
- Optionally, public blocklists such as Spamhaus DROP are imported and refused as well.
Anything that goes wrong inside the package, an unreachable Redis, a broken file, a PHP warning, lets the request through and is reported to your logger. The trap can make your site safer, never unavailable.
It is not a rate limiter and it does not stop floods. Leave that to the web server, a CDN or a WAF.
Quick start
You need PHP 8.1 or newer and Composer.
Create scanner-trap.php in your project root. A directory the web server can write to is all it needs:
Call the guard at the very top of public/index.php, right after the autoloader and before your application boots:
This works with any application that has a front controller, framework or not. A PSR-15 middleware is available too, see PSR-15 middleware.
Out of the box the trap runs in watch mode: it records scanners but refuses nobody. See who it catches:
Going live
Give watch mode a few days of real traffic, then go through this list before you switch refusing on.
- Run
vendor/bin/scanner-trap listand check that every address on it is a scanner. If you find one of your own users, the last two columns show which pattern caught them and what they requested. - If your application really serves a path that a decoy covers, a genuine
/adminfor example, add it toownPaths. Decoys never apply below those paths. - If the site sits behind a load balancer, a reverse proxy or a CDN, set
trustedProxies. Without it every request seems to come from the proxy, and the trap would end up blacklisting the proxy itself. - Configure a PSR-3
logger. It is the only way to find out that the trap failed open, for example because Redis was down. - Make sure scanner probes actually reach PHP, see Web server routing.
Then turn it on:
Choosing a setup
| Your situation | Local store | Central database | Sync |
|---|---|---|---|
| One server | files, Redis or APCu | none | none |
| Several servers, one Redis they all reach | the shared Redis | none | none |
| Several servers, each with its own Redis or disk | files or Redis per server | MySQL, MariaDB, PostgreSQL or SQLite | scanner-trap sync from cron |
Pick the first row that fits. A shared Redis is the simplest way to run several servers. The central database is for fleets without one, and it also keeps a full history of who was blocked, when and by which server.
One server
The file store from the quick start needs nothing but a writable directory. Redis works the same way on one server, and APCu works when you never need the command line (see Things to know).
The web server and the command line must run as the same user, otherwise one cannot write what the other created. Run the CLI as the web server user:
Several servers sharing one Redis
Point every server at the same Redis:
All servers read and write the same keys, so a scanner caught on one server is refused by all of them at once.
There is nothing to sync. If your application already has a Redis client, pass it instead of the connection
settings: 'local' => ['type' => 'redis', 'client' => $redis] accepts phpredis and Predis.
Several servers with a central database
Each server keeps its own fast local store, and a database holds the truth for all of them:
Create the tables once, from any server:
Then run the sync on every server from cron:
Each run sends the blocks made on this server to the database straight away and picks up everything the other
servers did, so a scanner caught anywhere is refused everywhere within a minute. Hits on different servers add up
for network blocking. If your project has its own long-running worker, it can call
ScannerTrap::fromConfig($config)->manager()->sync(50) instead of cron.
In this setup patterns and the whitelist live in the database. The patterns and allow keys of the config only
seed it on install, and from then on you manage them with the command line.
PSR-15 middleware
Applications built on PSR-15 can add the trap as middleware instead of calling guard():
A refused request gets a 403 from the response factory you pass in, every other request goes on to your
application.
Web server routing
The trap only sees requests that reach PHP. Two common nginx habits answer the most popular probes before PHP ever sees them, and those scanners then go unnoticed.
Missing .php files such as /wp-login.php or /xmlrpc.php get PHP-FPM's "File not found." page. Send them to your
front controller instead, in the location ~ \.php$ block:
Dot paths such as /.env and /.git/config are often closed with deny all;. Hand them to PHP instead, the file
itself is still never served:
What gets trapped
The default list, DefaultPatterns::LIST, is safe for any site. It covers secrets and VCS folders (/.env,
/.git, /.aws, /.ssh), admin tools (/phpmyadmin, /pma, /server-status, /actuator), backups and dumps
(*.sql, *.bak, *.php.old), well-known exploit paths (/vendor/phpunit, /cgi-bin, /boaform), SQL injection
fragments that only an attack produces (union select, ' or 1, from information_schema), and the User-Agents of
scanning tools (sqlmap, nikto, nuclei, zgrab and others).
A pattern is written in one of five forms:
| Form | Example | Matches |
|---|---|---|
| path | /phpmyadmin |
that path and everything below it, /phpmyadmin/index.php too, but not /phpmyadmin-docs |
| path prefix | /.env* |
every path starting with /.env, such as /.env.production |
| extension | *.sql |
any path ending in .sql |
| query fragment | ~union select |
the text anywhere in the decoded URL, ignoring case, comments and extra spaces |
| User-Agent | @sqlmap |
the text anywhere in the User-Agent, ignoring case, on any page |
Two more lists are opt-in:
| Constant | Add it when |
|---|---|
DefaultPatterns::WORDPRESS_PROBES |
your site is not WordPress: /wp-admin, /wp-login.php, /xmlrpc.php and friends |
DefaultPatterns::LOOSE_FRAGMENTS |
none of your query strings carries free text: fragments such as " or " and sleep( that a search box can produce |
Every pattern is validated before it is stored. The package refuses patterns that would hit real visitors, such as
*.js, a path covering one of your ownPaths, or a User-Agent fragment that every browser sends, like @mozilla.
Scanners that change address
Many scanners switch to a new address as soon as one gets blocked. Three features close that gap.
Network blocking is on by default. When three different addresses of one IPv4 /24 get caught within 24 hours,
the whole /24 is blocked. For IPv6 the first hit blocks its /64, because a single machine usually owns a whole
/64. Private and reserved networks are never blocked this way.
Be careful if many of your visitors come through mobile networks. Carriers put thousands of unrelated people behind
a few shared addresses (CGNAT), and a /24 there can hold real customers. Raise v4Threshold, use a narrower
v4Prefix, or turn the feature off. A whitelist entry always beats a network block.
Scanner signatures catch tools by their User-Agent on any page, decoy or not. The default list knows the usual
suspects, and you can add more with scanner-trap pattern:add '@mytool'.
Imported blocklists refuse known bad networks before they even try, see the next section.
Imported blocklists
spamhaus-drop and firehol-level1 are built in. Any other source is a URL or a local file with one IP or CIDR per
line. Lines starting with # or ; are comments, and 'format' => 'spamhaus-json' reads Spamhaus' JSON format.
Fetch the lists daily from cron:
The import skips private and reserved networks and anything wider than /8 for IPv4 or /16 for IPv6, and it caps
each download at 16 MB. If one source fails, the others are still imported and the failed one keeps its previous
entries. With a central database, run the import on one server only. The others receive the lists with their next
sync.
Listed addresses are refused while blocking is on. They are not blacklisted, so they disappear when the list drops
them, and a whitelist entry still wins. scanner-trap lists shows each source with its size and the time of its last
import.
Command line
The CLI reads ./scanner-trap.php unless you pass --config.
| Command | What it does |
|---|---|
list [--active] [--ip=IP] |
Shows blocks with their reason, server and expiry. With --ip, the address's own block and any network block covering it. |
block <ip or cidr> [--reason=TEXT] [--ttl=SECONDS] |
Blocks an address or a network by hand. --ttl=0 blocks forever. |
unblock <ip or cidr> |
Lifts a block on every server. |
allow:list, allow:add <entry> [--comment=TEXT] [--ttl=SECONDS], allow:remove <entry> |
Manages the whitelist: IPs, CIDR ranges and masks like 192.168.0.*. |
pattern:list, pattern:add <pattern>, pattern:remove <pattern> |
Manages the decoy patterns. |
import [--source=NAME] |
Fetches all configured blocklists, or one. |
lists |
Shows each blocklist source with its size and last import. |
install |
Creates the tables with a central database. Without one, writes the config's patterns and whitelist to the store, replacing what was added through the CLI. |
sync [--watch=SECONDS] |
Exchanges blocks with the central database, then keeps sending new blocks for the given time. |
prune [--keep=SECONDS] |
Deletes history older than 30 days, or the given time, from the central database and expired files from the file store. |
Exit codes are 0 for success, 1 for a usage error, 2 when the action was refused (an invalid pattern, say) and
3 when a store could not be reached. The CLI notes who acted, as user and host such as deploy@web1: as the reason
of a manual block, as the author of whitelist entries, and with a central database also as the author of patterns and
of lifted blocks.
Run prune daily. With file stores, run it on every server:
Configuration reference
| Key | Default | Meaning |
|---|---|---|
local |
required | Where blocks are kept on this server: ['type' => 'file', 'dir' => …], ['type' => 'redis', 'host', 'port', 'database', 'password', 'prefix', 'persistent'], ['type' => 'redis', 'client' => $redis] or ['type' => 'apcu']. |
central |
null |
The central database: ['dsn' => …, 'user' => …, 'password' => …, 'tablePrefix' => 'scanner_trap_']. |
blocking |
false |
true refuses blacklisted and listed addresses. false is watch mode: everything is recorded, nobody is refused. |
blockTtl |
604800 |
How long a block lasts, in seconds. 0 means forever. |
patterns |
DefaultPatterns::LIST |
The decoys. With a central database this only seeds install. |
allow |
[] |
Addresses that are never refused: IPs, CIDR ranges, masks like 192.168.0.*. With a central database this only seeds install. |
ownPaths |
[] |
Paths your site really serves. Path and extension decoys never apply below them. |
trustedProxies |
[] |
CIDR ranges of your load balancers or CDN. X-Forwarded-For is read only when the request comes from one of them. |
subnets |
see above | Network blocking. false turns it off. |
lists |
[] |
Blocklists to import. |
serverName |
the host name | Recorded with every block, so you can tell which server caught whom. |
logger |
null |
Any PSR-3 logger. Receives new blocks and every failure. |
Without a central database, the config's patterns and allow are used until you first change either of them with
the CLI. From then on the stored lists are used.
Things to know
Behind Cloudflare. Use the ranges that ship with the package, alone or together with your own:
Cloudflare rarely changes its ranges. New releases of the package update the constant.
Only trust proxies you control. X-Forwarded-For is easy to fake. It is read only for requests coming from
trustedProxies, and from the right, so a client cannot pick the address that gets judged.
Links from other sites. A hostile page could embed <img src="https://your-site/.env"> to get its visitors
blacklisted on your site. Browsers mark such requests with Sec-Fetch-Site: cross-site, and the trap refuses them
without blacklisting anyone. Older browsers do not send that header.
Links on your own site. The same trick works from inside your site, through user content such as a comment with an image pointing at a decoy. Everyone who views that page would be blacklisted. Sanitise user content, as you should anyway.
Search boxes. Queries like "rock" or "roll" look a lot like SQL injection. That is why the fragments a search
box can produce live in the opt-in LOOSE_FRAGMENTS list.
APCu. An APCu store lives inside one PHP-FPM pool. The command line cannot reach it, so you cannot list, import or unblock from the CLI, and it cannot follow a central database. Use it only when you need nothing but the guard.
Overhead. One store round trip per request. On a test machine with 6 000 imported networks the check took about half a millisecond with the file store and less with Redis.
Troubleshooting
Nobody gets caught. Check that probes reach PHP at all, see Web server routing, and that
requests do not all come from 127.0.0.1. Loopback addresses are never judged, so behind a local proxy you need
trustedProxies.
Your load balancer got blacklisted. Add its range to trustedProxies and run
scanner-trap unblock <its address>.
Nothing is refused although blocks are listed. blocking is still false, or the logger shows that the trap
failed open.
Files the CLI created are not writable by the web server, or the other way round. Run the CLI as the web server
user, or give both the same group and umask 002.
A corrupt networks.json or lists.current in the file store. The trap fails open for those lookups and logs
it. Delete the file. Network blocks come back with the next hit, imported lists with the next scanner-trap import.
sync refuses to run after you reinstalled the central database. Each local store remembers which database it
follows, so that two installations can never mix. Delete the {prefix}owner key in Redis or the owner file in the
file store on every server.
Design
docs/architecture.md explains how the package works inside, its data layout and the reasons behind its decisions.
Contributing
Bug reports and pull requests are welcome. CONTRIBUTING.md explains how to run the test suite. Please report security issues privately, as described in SECURITY.md.
License
MIT, see LICENSE.
All versions of scanner-trap with dependencies
psr/http-factory Version ^1.0
psr/http-message Version ^1.1 || ^2.0
psr/http-server-middleware Version ^1.0
psr/log Version ^1.1 || ^2.0 || ^3.0