Extend or debug JeffersonGoncalves\ScannerGuard — scanner path patterns, ban lifecycle, ASN blocklisting, and the nginx denylist export.
php artisan boost:add-skill jeffersongoncalves/laravel-scanner-guard
Save this content to: .claude/skills/laravel-scanner-guard-development/SKILL.md
# Laravel Scanner Guard Development
## When to use this skill
Use this skill when:
- Adding/tuning `scanner_paths` patterns for a specific app's traffic
- Debugging why an IP was (or wasn't) banned
- Working with the nginx denylist export and its cache-eviction tradeoff
- Extending `ScannerGuard`'s ban/hit-counting logic
## Core Concepts
### Two categories of scanner traffic
1. Literal nonexistent `.php` files — fail at nginx/PHP-FPM, never reach PHP. Not this package's
job; fix with `try_files $uri =404;`.
2. Extension-less/routable paths that reach Laravel's router as an ordinary 404 — this package's
entire job.
Never try to "fix" category 1 from inside `BlockScannerRequests` — it's structurally invisible to
PHP.
### Ban lifecycle
```
request -> isBanned()? -----------------------------> yes: abort(response_status)
-> asn_blocklist non-empty? -> isAsnBlocked()? -> yes: ban() + abort(response_status)
-> matchScannerPath()? ----------------------> no: pass through
-> registerHit() >= ban_threshold? -----------> yes: ban() + abort(response_status)
-> pass through
```
`isBanned()` checks cache first (fast path), then falls back to the `scanner_guard_bans` table if
the cache entry is missing — and re-hydrates the cache when it finds an active DB row. This means
a `cache:clear` never silently un-bans anyone; it only costs one extra DB query per previously
cached-but-now-uncached IP. A DB miss is cached as `false` for `negative_cache_ttl` seconds
(default 60, 0 = off), so non-banned traffic doesn't query the table on every request; `ban()`
overwrites that entry, so a fresh ban takes effect immediately.
### Privacy: hashed DB, raw IP only in cache
`ScannerGuardBan::ip_hash` is `IpAnonymizer::hash($ip)` (salted sha256, from
`jeffersongoncalves/laravel-visitor-fingerprint`) — irreversible by design. The raw IP is cached
separately at ban time (`scanner-guard:raw-ip:{hash}`, TTL = `ban_duration`) purely so
`scanner-guard:export-denylist` can write a real nginx `deny` line. If that cache entry is gone
(flush, eviction, "array" driver), the export skips that ban — the app-layer protection is
unaffected (it only needs the hash), only the nginx-side export lags.
```php
use JeffersonGoncalves\ScannerGuard\ScannerGuard;
$scannerGuard = app(ScannerGuard::class);
$scannerGuard->ban($ip, ScannerGuardBan::REASON_SCANNER_PATH, 'wp-admin*', hitCount: 3, path: $path);
$scannerGuard->rawIpFor($scannerGuard->hashIp($ip)); // string|null
```
## Common Patterns
### Adding app-specific scanner paths
```php
// config/scanner-guard.php
'scanner_paths' => array_merge(
config('scanner-guard.scanner_paths'),
['my-legacy-admin/*', 'old-api/v1/*'],
),
```
### Testing middleware behavior
Use `withServerVariables(['REMOTE_ADDR' => $ip])` to control the IP per test request — `TestCase`
uses the `array` cache driver by default, so each test run starts clean. To simulate an ASN-blocked
IP, bind a fake `GeoIpDriver` before the request:
```php
app()->bind(GeoIpDriver::class, fn () => new class implements GeoIpDriver {
public function resolve(string $ip): GeoLocation
{
return new GeoLocation(asn: 'AS16276 OVH SAS');
}
});
```
## Troubleshooting
### A legitimate user got banned
Check `ScannerGuardBan::query()->where('ip_hash', $hash)->first()` for the `matched_value` pattern
that triggered it — it's almost always an overly broad `scanner_paths` entry matching a real route
in the host app. Narrow the pattern rather than raising `ban_threshold` globally.
### The nginx export is missing entries I know are banned
Expected when the cache store doesn't retain the `scanner-guard:raw-ip:{hash}` entry for the full
`ban_duration` (an `array` driver, a `cache:clear`, LRU eviction). Switch `scanner-guard.store` to a
durable store (`redis`/`file`) if the nginx export matters to you.
## API Reference
### `ScannerGuard::isBanned(string $ip): bool`
Cache-first; DB fallback (re-hydrating the cache) only with the `database` driver.
### `ScannerGuard::matchScannerPath(string $path): ?string`
Returns the matched `fnmatch()` pattern, or `null`.
### `ScannerGuard::registerHit(string $ip): int`
Increments and returns the rolling hit counter (TTL = `ban_window`).
### `ScannerGuard::ban(string $ip, string $reason, string $matchedValue, int $hitCount, ?string $path = null): ?ScannerGuardBan`
Writes the cache ban + raw-IP cache entry, then per `scanner-guard.driver`: the permanent DB row
(`database`, returned), an HMAC-signed POST to `http.url` after the response (`http`, returns
`null`) or nothing more (`cache`, returns `null`). Always logs a WARNING-level
`scanner-guard: banned ip` line (includes the raw IP — this log line is the one deliberate
exception to "never log a raw IP" in this ecosystem, since it's operational security telemetry, not
persisted user data).
Idempotent per IP for 60 seconds (atomic cache `add()`): concurrent requests of one burst that all
pass `isBanned()` before the first ban is cached get `null` and store nothing, so a burst counts as
one offense. `unban()` clears this guard.
### `ScannerGuard::recordForwardedBan(string $ip, string $reason, string $matchedValue, int $hitCount, CarbonInterface $bannedAt, CarbonInterface $expiresAt, ?int $offense = null): ?ScannerGuardBan`
Central-app side of the `http` driver (behind the `http.server.enabled` route): records the ban
like a local one, hashing the IP with this app's salt. Same 60-second per-IP guard as `ban()`, so
several satellites forwarding one burst store a single row (`null` for the duplicates).
### `ScannerGuard::rawIpFor(string $ipHash): ?string`
Best-effort raw IP lookup for the export command.
Use this skill when:
scanner_paths patterns for a specific app's trafficScannerGuard's ban/hit-counting logic.php files — fail at nginx/PHP-FPM, never reach PHP. Not this package's
job; fix with try_files $uri =404;.Never try to "fix" category 1 from inside BlockScannerRequests — it's structurally invisible to
PHP.
request -> isBanned()? -----------------------------> yes: abort(response_status)
-> asn_blocklist non-empty? -> isAsnBlocked()? -> yes: ban() + abort(response_status)
-> matchScannerPath()? ----------------------> no: pass through
-> registerHit() >= ban_threshold? -----------> yes: ban() + abort(response_status)
-> pass through
isBanned() checks cache first (fast path), then falls back to the scanner_guard_bans table if
the cache entry is missing — and re-hydrates the cache when it finds an active DB row. This means
a cache:clear never silently un-bans anyone; it only costs one extra DB query per previously
cached-but-now-uncached IP. A DB miss is cached as false for negative_cache_ttl seconds
(default 60, 0 = off), so non-banned traffic doesn't query the table on every request; ban()
overwrites that entry, so a fresh ban takes effect immediately.
ScannerGuardBan::ip_hash is IpAnonymizer::hash($ip) (salted sha256, from
jeffersongoncalves/laravel-visitor-fingerprint) — irreversible by design. The raw IP is cached
separately at ban time (scanner-guard:raw-ip:{hash}, TTL = ban_duration) purely so
scanner-guard:export-denylist can write a real nginx deny line. If that cache entry is gone
(flush, eviction, "array" driver), the export skips that ban — the app-layer protection is
unaffected (it only needs the hash), only the nginx-side export lags.
use JeffersonGoncalves\ScannerGuard\ScannerGuard;
$scannerGuard = app(ScannerGuard::class);
$scannerGuard->ban($ip, ScannerGuardBan::REASON_SCANNER_PATH, 'wp-admin*', hitCount: 3, path: $path);
$scannerGuard->rawIpFor($scannerGuard->hashIp($ip)); // string|null
// config/scanner-guard.php
'scanner_paths' => array_merge(
config('scanner-guard.scanner_paths'),
['my-legacy-admin/*', 'old-api/v1/*'],
),
Use withServerVariables(['REMOTE_ADDR' => $ip]) to control the IP per test request — TestCase
uses the array cache driver by default, so each test run starts clean. To simulate an ASN-blocked
IP, bind a fake GeoIpDriver before the request:
app()->bind(GeoIpDriver::class, fn () => new class implements GeoIpDriver {
public function resolve(string $ip): GeoLocation
{
return new GeoLocation(asn: 'AS16276 OVH SAS');
}
});
Check ScannerGuardBan::query()->where('ip_hash', $hash)->first() for the matched_value pattern
that triggered it — it's almost always an overly broad scanner_paths entry matching a real route
in the host app. Narrow the pattern rather than raising ban_threshold globally.
Expected when the cache store doesn't retain the scanner-guard:raw-ip:{hash} entry for the full
ban_duration (an array driver, a cache:clear, LRU eviction). Switch scanner-guard.store to a
durable store (redis/file) if the nginx export matters to you.
ScannerGuard::isBanned(string $ip): boolCache-first; DB fallback (re-hydrating the cache) only with the database driver.
ScannerGuard::matchScannerPath(string $path): ?stringReturns the matched fnmatch() pattern, or null.
ScannerGuard::registerHit(string $ip): intIncrements and returns the rolling hit counter (TTL = ban_window).
ScannerGuard::ban(string $ip, string $reason, string $matchedValue, int $hitCount, ?string $path = null): ?ScannerGuardBanWrites the cache ban + raw-IP cache entry, then per scanner-guard.driver: the permanent DB row
(database, returned), an HMAC-signed POST to http.url after the response (http, returns
null) or nothing more (cache, returns null). Always logs a WARNING-level
scanner-guard: banned ip line (includes the raw IP — this log line is the one deliberate
exception to "never log a raw IP" in this ecosystem, since it's operational security telemetry, not
persisted user data).
Idempotent per IP for 60 seconds (atomic cache add()): concurrent requests of one burst that all
pass isBanned() before the first ban is cached get null and store nothing, so a burst counts as
one offense. unban() clears this guard.
ScannerGuard::recordForwardedBan(string $ip, string $reason, string $matchedValue, int $hitCount, CarbonInterface $bannedAt, CarbonInterface $expiresAt, ?int $offense = null): ?ScannerGuardBanCentral-app side of the http driver (behind the http.server.enabled route): records the ban
like a local one, hashing the IP with this app's salt. Same 60-second per-IP guard as ban(), so
several satellites forwarding one burst store a single row (null for the duplicates).
ScannerGuard::rawIpFor(string $ipHash): ?stringBest-effort raw IP lookup for the export command.
How can I help you explore Laravel packages today?