Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
laravel-scanner-guard-development
jeffersongoncalves/laravel-scanner-guard
claude-code
security
firewall
laravel
ban
waf
scanner-guard

Extend or debug JeffersonGoncalves\ScannerGuard — scanner path patterns, ban lifecycle, ASN blocklisting, and the nginx denylist export.

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

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.

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

// 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:

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator
equipc/laravel-email-database-log
adedsec/nova-translated-labels
wazza/laravel-db-encryption
hokny/laravel-generator
vonsogt/backup
jengo/queues
jeremykenedy/laravel-observability
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names