jeffersongoncalves / laravel-scanner-guard
Detect and ban vulnerability-scanner traffic on Laravel apps, with optional ASN-level blocking via laravel-visitor-fingerprint
Package info
github.com/jeffersongoncalves/laravel-scanner-guard
pkg:composer/jeffersongoncalves/laravel-scanner-guard
Requires
- php: ^8.2
- illuminate/contracts: ^12.0|^13.0
- jeffersongoncalves/laravel-visitor-fingerprint: ^1.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- jeffersongoncalves/pest-plugin-database-migrations: ^1.0
- larastan/larastan: ^3.0
- laravel/pint: ^1.24
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Laravel Scanner Guard
Detects and bans vulnerability-scanner traffic on Laravel apps: WordPress/CMS/credential probes,
.git/config leaks, phpMyAdmin, and similar noise get counted per IP and banned after a threshold,
with optional hard-blocking by ASN via
jeffersongoncalves/laravel-visitor-fingerprint.
A SQL database is optional: bans can also live in the cache only, or be forwarded to a central app
over HMAC-signed requests (see Running without a database).
What this package does — and does not — handle
Vulnerability-scanner traffic against a Laravel app splits into two categories:
-
Literal nonexistent
.phpfiles (/wp/index.php,/wordpress/index.php,/blog/index.php, ...). These fail at the nginx/PHP-FPM layer — PHP-FPM logs "Unable to open primary script" — before Laravel's front controller ever boots. No PHP code, this package included, can see these requests at all. The fix lives in your nginx config, not in this package:location ~ \.php$ { try_files $uri =404; # ... fastcgi_pass, etc. }
-
Extension-less or otherwise-routable paths that DO reach Laravel and get handled as an ordinary 404 by the router (
/wp-admin,/wp-login,/.git/config,/phpmyadmin,/wp-json, ...). This is what this package handles: it counts repeat offenders per IP and bans them so later requests get a fast, cheap rejection instead of running through the full framework boot + routing + 404 view render.
If you're only seeing nginx-layer 404s (category 1) in your access logs, this package has nothing
to catch — fix try_files first.
Installation
You can install the package via composer:
composer require jeffersongoncalves/laravel-scanner-guard
Publish the config file and run the migration:
php artisan vendor:publish --tag="scanner-guard-config"
php artisan migrate
The migrations are only needed for the default database driver — see
Running without a database.
Usage
The package registers a scanner-guard route middleware alias but does not attach it to any
group automatically — opt in yourself, e.g. in bootstrap/app.php:
use JeffersonGoncalves\ScannerGuard\Http\Middleware\BlockScannerRequests; ->withMiddleware(function (Middleware $middleware) { $middleware->appendToGroup('web', BlockScannerRequests::class); })
or attach it to a specific route group instead:
Route::middleware('scanner-guard')->group(function () { // ... });
Once attached: a request matching one of scanner-guard.scanner_paths increments a per-IP hit
counter; after ban_threshold hits within ban_window seconds, the IP is banned for
ban_duration seconds. A banned IP gets an immediate response_status (404 by default —
deliberately indistinguishable from "route doesn't exist", so a scanner can't tell it got banned
and adjust its behavior).
404 flood
scanner_paths only catches software it names. To also ban scanners probing for anything else
(MediaWiki images/thumb/..., random index.php/<id> paths), opt into the 404-flood rule:
SCANNER_GUARD_NOT_FOUND_THRESHOLD=20 SCANNER_GUARD_NOT_FOUND_WINDOW=60
An IP that gets not_found_threshold 404 responses within not_found_window seconds is banned
with reason not_found_flood. The 404 that trips it is served normally; the ban applies from the
next request. Only 404s produced by routes the middleware runs on are counted. To count unmatched
URLs too, attach it globally or add a catch-all route. Route::fallback() only matches GET/HEAD
(other methods to an unknown URL get a 405 and aren't counted), so for every method use:
Route::any('{any}', fn () => abort(404))->where('any', '.*')->fallback()->middleware('scanner-guard');
Escalating bans for repeat offenders
By default every ban lasts ban_duration, so a scanner that comes back the day after its ban
expires just gets another day. Opt into an escalation ladder (seconds per offense; the last step
repeats):
// config/scanner-guard.php 'escalation' => [86400, 604800, 1209600, 2592000, 7776000], // 1d → 7d → 14d → 30d → 90d 'escalation_lookback_days' => 365, // env SCANNER_GUARD_ESCALATION_LOOKBACK_DAYS 'escalation_reasons' => [ScannerGuardBan::REASON_SCANNER_PATH, ScannerGuardBan::REASON_NOT_FOUND_FLOOD, ScannerGuardBan::REASON_USER_AGENT],
A ban is offense N+1 when the same IP had a ban numbered N for one of escalation_reasons within
escalation_lookback_days. Reasons outside that list (e.g. asn_blocklist) keep ban_duration
and don't count as prior offenses. The offense number is stored on the ban row (offense) and
logged with the ban.
database: run theadd_offense_to_scanner_guard_bans_tablemigration (published with the others; existing installs: re-publish andphp artisan migrate).cache/http: the count lives in a cache entry (scanner-guard:offenses:{hash}, TTL = lookback), so a cache flush resets it to the first step. Withhttp, the satellite decides the escalation and forwardsoffenseandexpires_at; the central app stores them as-is.
Retention: with escalation on,
aggregate-and-prunekeeps each IP's latest escalated ban inside the lookback (older/duplicate expired rows are still purged), and prunes only rows pastmax(retention_days, escalation_lookback_days). Without that row the next ban would start over at the first step — so this is why ban rows can outliveretention_days.
ASN blocklisting
Some traffic is worth blocking outright, no path match needed. A production analysis of this
package's own author's site found a single ASN (AS16276, OVH SAS) responsible for 70-80% of all
bot traffic with zero legitimate human visitors ever coming from it. Opt in per-app:
// config/scanner-guard.php 'asn_blocklist' => ['AS16276'],
ASN resolution is delegated to jeffersongoncalves/laravel-visitor-fingerprint's GeoIpDriver —
only the ip_api and maxmind drivers populate an ASN (the default headers driver doesn't), so
set visitor-fingerprint.geoip.driver accordingly for this to have any effect. When the blocklist
is empty (the default), the GeoIP driver is never even resolved — zero added latency/cost.
IP / CIDR blocklisting
For IPs or ranges you've already identified (e.g. from Cloudflare analytics), no GeoIP needed:
// config/scanner-guard.php 'ip_blocklist' => ['185.177.72.69', '185.177.72.0/24', '2001:db8::/32'],
Matching requests get response_status before any counting, checked with
Symfony\Component\HttpFoundation\IpUtils::checkIp() (IPv4 + IPv6). These are not bans: no row is
written and they never expire. scanner-guard:export-denylist always writes them as
deny <ip-or-cidr>; # static lines, so with nginx sync on they never reach PHP at all. Invalid
entries are skipped by the export (with a warning), so a typo can't break nginx -t.
User-Agent blocklisting
Opt-in, empty by default. Catches scripted clients and blank User-Agents regardless of path:
// config/scanner-guard.php 'user_agent_blocklist' => [ 'block_empty' => true, // env SCANNER_GUARD_BLOCK_EMPTY_USER_AGENT 'patterns' => ['zgrab*', '*masscan*', 'python-requests/*'], 'action' => 'ban', // or 'reject'; env SCANNER_GUARD_USER_AGENT_ACTION ],
Patterns are fnmatch()-style, case-insensitive, matched against the full UA string as-is:
nothing exempts verified crawlers, and a broad pattern such as *curl* also locks out your own
scripts, uptime monitors and health checks. ban (default) rejects and records a ban with reason
user_agent (stored with the matched pattern, or empty), so it escalates, shows up in the daily
stats and reaches the nginx export like any other ban; reject only answers with
response_status, nothing recorded.
Runtime blocklist (database)
With the database driver, IPs / CIDR ranges and UA patterns can also live in the
scanner_guard_blocklist table (run the create_scanner_guard_blocklist_table migration), so they
change without a config edit or a redeploy. They are added on top of ip_blocklist and
user_agent_blocklist.patterns, which keep applying:
use JeffersonGoncalves\ScannerGuard\Models\ScannerGuardBlocklistEntry; ScannerGuardBlocklistEntry::query()->create(['type' => ScannerGuardBlocklistEntry::TYPE_IP, 'value' => '185.177.72.0/24', 'note' => 'Cloudflare report']); ScannerGuardBlocklistEntry::query()->create(['type' => ScannerGuardBlocklistEntry::TYPE_USER_AGENT, 'value' => '*masscan*']);
The rows are cached in the scanner-guard store under a version key, so a check costs two cache
reads and no query. Saving or deleting a model, once its transaction commits, moves to a new
version (the next check, in any web, queue or Octane worker, sees the change) and, with
sync_to_nginx, queues the nginx export (nginx.export_on_ban only gates exports on new bans). A
rolled-back change does neither. A mass query()->delete() / update() fires no model events: call
ScannerGuard::blocklistChanged() after one. If the table is missing, the config lists still apply
and the error is reported.
The migration skips creating the table when it already exists, so apps that ran the one from
filament-scanner-guard keep their rows. For the same reason, rolling it back keeps the table.
Unbanning
use JeffersonGoncalves\ScannerGuard\Facades\ScannerGuard; ScannerGuard::unban($ban->ip_hash); // number of active ban rows deleted
Deleting the ban row alone isn't enough: the cached ban would keep answering "banned" until it
expires. unban() deletes the active rows for that hash (database driver), forgets the cached ban
and its raw IP, and with sync_to_nginx queues the nginx export. A failing export dispatch is
logged, never thrown.
Manual review
With the default database driver, every ban writes a row to scanner_guard_bans (reason,
matched_value, hit_count, banned_at, expires_at) — a permanent, privacy-safe audit trail
(with the http driver, it lives on the central app):
use JeffersonGoncalves\ScannerGuard\Models\ScannerGuardBan; ScannerGuardBan::query()->active()->latest('banned_at')->get();
Exporting to nginx
php artisan scanner-guard:export-denylist
# or a custom path:
php artisan scanner-guard:export-denylist --path=storage/app/scanner-guard/denylist.conf
Writes every currently-active ban as an nginx deny <ip>; line. include it from your server
block to close the loop with edge-level blocking:
server { include storage/app/scanner-guard/denylist.conf; # ... }
Nothing schedules this automatically unless you opt in via scanner-guard.sync_to_nginx = true
(env SCANNER_GUARD_SYNC_TO_NGINX), which self-schedules the export every minute (cheap: the file
is only rewritten when its content changes) so expired bans leave nginx promptly — left off by default
because writing files to disk on a schedule is far more deployment-specific (permissions, shared
vs. per-instance storage, whether nginx actually reloads the file) than a DB-only aggregate job.
With sync_to_nginx on, every new ban (local or forwarded) also queues a unique
ExportDenylist job (scanner-guard.nginx.export_on_ban, env
SCANNER_GUARD_NGINX_EXPORT_ON_BAN, default true), so the file follows the ban table within
seconds; a burst of bans collapses into one export. The scheduled run stays as a safety net and is
what drops expired bans. Every export runs under one cache lock (scanner-guard.store), so a
slower export never overwrites a newer snapshot. The default path comes from scanner-guard.nginx.path
(env SCANNER_GUARD_NGINX_PATH) so the scheduled run and the job agree on it; --path still
overrides it. The file is written to a temp file and renamed over the target, so nginx never reads
a half-written list.
nginx only re-reads an included file on reload. Set scanner-guard.nginx.reload_command
(env SCANNER_GUARD_NGINX_RELOAD_COMMAND, e.g. sudo nginx -s reload) and it runs after an
export only when the file content changed; a non-zero exit or a timeout is logged, never thrown. The PHP
user (and your queue worker, if different) must be allowed to run it without a password, e.g. in
/etc/sudoers.d/scanner-guard (Forge's user is forge):
forge ALL=NOPASSWD: /usr/sbin/nginx -s reload
Aggregating and pruning
Every ban bumps a scanner_guard_ban_daily_stats row for the day it was created (bans_count,
hits_total, reason_stats, top_matched_values — aggregate columns only, no per-IP data). The
history is recorded at ban time, so it survives any later delete of the ban row: purge, unban or
retention prune.
scanner-guard:aggregate-and-prune then, in order:
- reconciles every day from the oldest remaining ban up to yesterday against the ban rows still
present — filling days the ban-time counter missed, never lowering a counter (
max(existing, recomputed), since deleted rows can't be recounted); - purges expired bans once their day is recorded, after
scanner-guard.purge_expired_after_days(default0= next run;nulldisables); - deletes ban rows expired more than
scanner-guard.retention_days(default 90) ago.
Still-active bans are never purged or pruned. With escalation on, each IP's latest escalated ban inside the lookback is kept too.
php artisan scanner-guard:aggregate-and-prune # reconcile from a specific day: php artisan scanner-guard:aggregate-and-prune --date=2026-01-15 # overwrite the stats from the remaining ban rows instead of keeping the higher value: php artisan scanner-guard:aggregate-and-prune --rebuild
Read the history (one entry per day, zero-filled, today live):
ScannerGuard::dailyStats(14); // Collection of ['date', 'bans_count', 'hits_total', 'reason_stats', 'top_matched_values']
Upgrading: releases before this change keyed stats rows by the day a ban expired (one day late with the default
ban_duration). Runphp artisan scanner-guard:aggregate-and-prune --rebuildonce right after upgrading — before the scheduled run purges the expired rows — to rebuild those days bybanned_at.
Self-schedules daily unless you opt out via scanner-guard.auto_prune = false (env
SCANNER_GUARD_AUTO_PRUNE) — on by default, since (unlike sync_to_nginx) writing to the DB has no
deployment-specific footgun.
Privacy tradeoff
scanner_guard_bans only ever stores a salted hash of the IP (ip_hash, via
jeffersongoncalves/laravel-visitor-fingerprint's IpAnonymizer::hash()) — never the raw address,
matching this ecosystem's other packages (laravel-page-visits, laravel-short-url). A hash can't
be reversed back into an IP, which is exactly the point — but an nginx deny line needs a real IP.
The chosen tradeoff: at ban time, the raw IP is cached separately (keyed by the same hash, TTL =
ban_duration) — cache only, never the permanent table. scanner-guard:export-denylist reads
active bans from the database, then looks up each one's raw IP in that cache entry. If the cache
entry has expired or been evicted (a cache:clear, an "array" driver that doesn't survive between
requests, LRU eviction under memory pressure, ...), that ban is silently skipped from the nginx
export — the command logs how many were skipped, but does not fail.
This means: the database never contains a reversible IP, and the app-layer ban still applies
correctly regardless (the fast-path/DB-fallback ban check in isBanned() only ever needs the
hash). The nginx denylist is best-effort and may lag or miss entries after a cache flush. If you
rely heavily on the nginx export, prefer a durable cache store (redis, file) over array via
scanner-guard.store.
Running without a database
Hit counting and the ban fast-path always run on the cache store; only the audit trail, daily
stats, nginx export and the isBanned() DB fallback need SQL. Pick where bans go with
scanner-guard.driver (env SCANNER_GUARD_DRIVER):
| Driver | Bans stored in | Audit trail / stats / nginx export | Needs migrations |
|---|---|---|---|
database (default) |
cache + scanner_guard_bans |
yes | yes |
http |
cache, forwarded to a central app | on the central app | no |
cache |
cache only, TTL = ban_duration |
no | no |
With http or cache, a ban doesn't survive a cache flush, ScannerGuard::ban() returns null,
and aggregate-and-prune / export-denylist are neither scheduled nor run (they print a skip
notice).
http: one central app for many satellites
On each satellite (no database):
SCANNER_GUARD_DRIVER=http SCANNER_GUARD_HTTP_URL=https://central.example.com/scanner-guard/bans SCANNER_GUARD_HTTP_SECRET=a-long-random-shared-secret
On the central app (database driver, migrations run):
SCANNER_GUARD_HTTP_SERVER_ENABLED=true SCANNER_GUARD_HTTP_SECRET=a-long-random-shared-secret # optional, default scanner-guard/bans: SCANNER_GUARD_HTTP_SERVER_PATH=scanner-guard/bans
Each ban is POSTed as JSON (ip, reason, matched_value, hit_count, path, banned_at,
expires_at, offense) with X-Timestamp and X-Signature: hash_hmac('sha256', timestamp.body, secret).
The central app rejects bad signatures and timestamps outside http.tolerance (default 300s) with
403, and a replayed signature with 409. It then records the ban exactly like a local one: its
own salted ip_hash in the table, daily stats, and the raw IP in cache only — so its nginx export
covers every satellite.
The satellite sends the request after the response (defer()), with http.timeout (default 2s);
a failure is logged and never affects the request or the local (cache) ban.
Configuration
// config/scanner-guard.php return [ 'enabled' => true, 'driver' => 'database', // database | http | cache 'http' => [ 'url' => null, 'secret' => null, 'timeout' => 2, 'tolerance' => 300, 'server' => ['enabled' => false, 'path' => 'scanner-guard/bans'], ], 'table' => 'scanner_guard_bans', 'scanner_paths' => [ 'wp-admin*', 'wp-login*', 'xmlrpc.php', '*.git/config', '*phpmyadmin*', /* ... */ ], 'ban_threshold' => 3, 'ban_window' => 300, 'ban_duration' => 86400, 'escalation' => [], // e.g. [86400, 604800, 1209600, 2592000, 7776000] 'escalation_lookback_days' => 365, 'escalation_reasons' => ['scanner_path', 'not_found_flood', 'user_agent'], 'not_found_threshold' => null, 'not_found_window' => 60, 'asn_blocklist' => [], 'ip_blocklist' => [], 'user_agent_blocklist' => ['block_empty' => false, 'patterns' => [], 'action' => 'ban'], 'store' => null, 'response_status' => 404, 'sync_to_nginx' => false, 'nginx' => [ 'path' => 'storage/app/scanner-guard/denylist.conf', 'export_on_ban' => true, 'reload_command' => null, ], 'daily_stats_table' => 'scanner_guard_ban_daily_stats', 'retention_days' => 90, 'purge_expired_after_days' => 0, 'auto_prune' => true, ];
scanner_paths accepts fnmatch()-style wildcards matched case-insensitively against the request
path (no leading slash) — extend the array freely for paths specific to your own app.
Testing
composer test
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security
If you discover any security related issues, please email the author instead of using the issue tracker.
Credits
License
The MIT License (MIT). Please see License File for more information.
