Enumeration & IDOR Detection
The siem.enum middleware detects resource enumeration — scripts walking through your object IDs (photos/1, photos/2, photos/3, ...) hunting for exposed data or Insecure Direct Object Reference (IDOR) holes. It's behavioural detection: it looks for access patterns no human browsing session produces.
Usage#
Attach to routes with parameters:
Route::get('/photos/{photo}', [PhotoController::class, 'show'])
->middleware('siem.enum');
Route::middleware('siem.enum')->group(function () {
Route::get('/orders/{order}', ...);
Route::get('/invoices/{invoice}', ...);
Route::get('/users/{user}/profile', ...);
});
Routes without parameters are ignored (nothing to enumerate — plain 404 floods are abuse detection's job).
The three patterns#
Tracking is per IP + route pattern (photos/{photo}), inside a fixed 5-minute window:
1. sequential_scan — constant-stride ID walking#
Numeric IDs advancing by the same step: 1, 2, 3, 4, 5 (stride 1) or 10, 20, 30, 40 (stride 10). Five consecutive constant-stride hits (default) trigger the detection. Strides above max_stride (default 1000) don't count — jumping from ID 5 to 90210 isn't a scan step.
This is the classic dump-the-table script. Humans click links; they don't request arithmetically consecutive resources.
2. high_distinct_ids — abnormal breadth#
Fifteen distinct IDs (default) touched on one route pattern within the window — regardless of order or type, so it also catches shuffled scans and non-numeric identifiers (slugs, UUIDs). A human reads a few photos; a scraper reads every photo.
3. high_miss_ratio — guessing what isn't yours#
At least 50% of responses being 404/403/410 across ten or more requests (defaults). This is the IDOR-probing signature: the attacker doesn't know which IDs exist or which belong to them, so most guesses miss. Your authorization layer says "no" over and over — this detection makes the pattern of "no"s visible.
The event#
{
"event": { "action": "resource_enumeration_detected", "severity": 7 },
"threat": { "technique": { "id": ["T1119"], "name": ["Automated Collection"] } },
"source": { "ip": "203.0.113.9" },
"laravel": {
"context": {
"pattern": "sequential_scan",
"resource": "photos/{photo}",
"distinct_ids": 14,
"sequential_run": 9,
"stride": 1,
"requests_in_window": 14,
"miss_count": 11,
"window_minutes": 5,
"sample_ids": ["6", "7", "8", "9", "10"],
"auto_blocked": false
}
}
}
Everything an analyst needs: which resource, which pattern, how deep the scan got, and a sample of the probed IDs.
Noise control & windowing#
- Each pattern fires once per IP per route pattern per window.
- The window is fixed, not sliding — its expiry is set at the first request and doesn't refresh. This matters: a sliding window that resets its TTL on every hit would let a slow scanner keep the window open forever without counters ever resetting.
- Grouping by route pattern (not URL) means
photos/1andphotos/2are the same bucket, butphotos/{id}andorders/{id}are scored independently.
Auto-blocking#
SIEM_ENUM_AUTOBLOCK=true
Blocks the scanner for enumeration.block_minutes (default 60) via the blocklist, with reason enumeration:<pattern>.
Configuration#
| Key | Env | Default |
|---|---|---|
enumeration.window_minutes |
— | 5 |
enumeration.sequential_threshold |
SIEM_ENUM_SEQUENTIAL |
5 |
enumeration.max_stride |
— | 1000 |
enumeration.distinct_threshold |
SIEM_ENUM_DISTINCT |
15 |
enumeration.miss_ratio |
— | 0.5 |
enumeration.miss_min_requests |
— | 10 |
enumeration.auto_block |
SIEM_ENUM_AUTOBLOCK |
false |
enumeration.block_minutes |
— | 60 |
Tuning tips#
- Paginated APIs — clients legitimately walking
?page=1,2,3are fine (query strings aren't route parameters), but if you paginate via route params (/photos/page/{n}), skipsiem.enumon those routes or raisesequential_threshold. - Gallery-style UX — if "next photo" buttons produce sequential IDs from real users, raise
sequential_thresholdto a depth no human paging session reaches (e.g. 25), or switch those URLs to UUIDs and lean onhigh_distinct_idsinstead. - This detects, it doesn't authorize. Enumeration detection tells you someone is probing for IDOR; it does not fix an IDOR. Keep authorization in policies — and treat a
high_miss_ratioevent as a prompt to check what the attacker found before you blocked them.