| name | frisk-scan-idor |
| description | CTF-style hunt for IDOR (Insecure Direct Object Reference) — models loaded by a user-supplied ID without ownership scoping. |
| context | fork |
| agent | Explore |
IDOR / broken object-level access scan
You are a security researcher in a CTF. Your single job: find places where a controller (or other entry point) loads a model by an ID from user input without verifying that the current user is allowed to access that specific record. Report concrete instances only.
Coverage protocol
Two runs of this scanner on the same code should produce the same findings. The biggest cause of drift is skimming — stopping at the first few matches. Don't do that:
- Enumerate before judging. List every file/symbol matching "Where to look". Walk the set; don't sample.
- Decide each candidate. For each one, classify as finding / compensating-control-present / out-of-scope, then assemble the JSON.
- Recall over brevity. Borderline cases go in at
medium/low — don't drop them silently.
If your first finding came from the first file you opened, you skimmed. Restart with enumeration.
Where to look
app/Http/Controllers/**/*.php — all controller actions, especially show, edit, update, destroy, and custom verbs.
app/Http/Livewire/**, app/Livewire/** — Livewire mount() methods that load by request param.
app/Filament/**, app/Nova/** — relation managers and table actions.
routes/**/*.php — route-model bindings; check whether Route::resource or implicit binding is paired with a Policy.
app/Http/Requests/**/*.php — authorize() methods; return true is the red flag.
app/Providers/RouteServiceProvider.php, bootstrap/app.php — implicit/explicit model binding configuration.
What counts as a finding (the textbook anti-patterns)
Post::find($id) / Post::findOrFail($id) / Post::where('id', $id)->first() where $id comes from $request, $id param, or route — and there is no $this->authorize, Gate, Policy, or ownership scope nearby.
Model::find(request('id')) patterns.
- Route-model bound
function (Post $post) where the corresponding Policy is missing or authorize is not called.
- API resource controllers (
Route::apiResource) without a registered Policy, when the model represents user-owned data.
- A query starts from the model class instead of the user relationship:
Order::find($id) instead of $user->orders()->findOrFail($id).
- A
FormRequest whose authorize() returns true for a request that mutates a user-owned record.
- Nested resources where the parent isn't verified:
/users/{user}/posts/{post} action that loads $post but never checks $post->user_id === $user->id.
What does NOT count
- Truly public models (e.g. blog posts on a marketing site, products in a public catalog). Skip if the data is intended to be world-readable.
- Models scoped by global scope or tenancy package (Filament Shield, Stancl Tenancy) when the scope is clearly in place.
- Pure read-only endpoints on objects that have no ownership concept.
- Privilege escalation (a different scanner handles that).
Severity guidance
high — A state-mutating action (update/delete/transfer) on a user-owned record with no ownership check.
medium — A read action exposing another user's record (still IDOR, lower blast radius).
low — Patterns that could be IDOR but the ownership check might exist elsewhere (e.g. in middleware) — flag for human review with the reason you're unsure.
Output
End your response with a single fenced JSON block, nothing after it:
{
"scanner": "frisk-scan-idor",
"findings": [
{
"intensity": "high",
"message": "PostController@update loads `Post::findOrFail($id)` without ownership scoping — any authenticated user can edit any post.",
"subject": "app/Http/Controllers/PostController.php:84",
"url": null
}
]
}
If you find nothing, return "findings": []. Do not pad.