809 lines
45 KiB
Markdown
809 lines
45 KiB
Markdown
---
|
||
name: filament-security-audit
|
||
description:
|
||
Audit a Filament v5 application for security issues and write a per-finding
|
||
remediation plan. Use when asked to security-audit, security-review, harden,
|
||
or pen-test a Filament panel, resource, page, or Livewire component.
|
||
---
|
||
|
||
# Filament Security Audit
|
||
|
||
Audit how the application _uses_ Filament v5, not Filament's own source.
|
||
|
||
> Your output is a specification document. The implementing agent will only see
|
||
> your plan, so every finding must name the exact component, namespace, method,
|
||
> and docs URL needed to fix it — no guessing.
|
||
|
||
## How to Scan
|
||
|
||
**Search-anchored, never file-by-file.** Each catalogue check gives you a
|
||
search; run it. Then inspect only the code around each hit. If you open a file
|
||
no search pointed you to, stop. Run every search across all source roots (`app/`
|
||
and any namespaced roots; Blade under `resources/views`, plus mail /
|
||
notification / PDF view roots). For multi-panel apps, first note which
|
||
resources/pages belong to which — authorization expectations differ per panel.
|
||
**Carve-out:** panel provider classes, `config/`, `.env`, and `composer.json`
|
||
are always in scope — open them whenever a check needs to resolve a disk,
|
||
default, panel setting, or dependency version.
|
||
|
||
Check shape tags:
|
||
|
||
- **`[Site]`** — search finds the vulnerable construct directly. Inspect each
|
||
hit.
|
||
- **`[Seed]`** — search finds a seed set (policies, owner FKs, custom Livewire
|
||
components); inspect related code only.
|
||
- **`[Conditional]`** — finding only when a precondition holds. Verify **Flag
|
||
if** before reporting.
|
||
|
||
Highest-yield checks if time-boxed: [A1], [A2], [B1], [C1], [D1].
|
||
|
||
### Run in parallel with subagents
|
||
|
||
Every check is an independent search, so this audit parallelises cleanly. If you
|
||
can spawn subagents, partition by category (A–E); each returns structured
|
||
findings (check ID, location, component, fix) — not prose — and the orchestrator
|
||
merges and writes the plan. No subagents? Run sequentially.
|
||
|
||
## Writing the Plan
|
||
|
||
A single Markdown document with the sections below. The reader will read every
|
||
finding — there are no severity ratings; findings are grouped by check category
|
||
(A–E) in §2 so similar issues sit next to each other.
|
||
|
||
- **Flag only what's actually exploitable in _this_ codebase.** A C2 case whose
|
||
sink is sanitised by the framework is not a finding. The catalogue describes
|
||
what to look for; what _fires_ depends on the conditions in the codebase. When
|
||
in doubt, lean toward `Pass` — noise is the failure mode this skill exists to
|
||
suppress.
|
||
- **Consolidate systemic findings.** A check firing across many locations gets
|
||
**one** entry with a list of affected locations, not one per occurrence.
|
||
- **`Pass` / `N/A` is not a finding.** Don't raise a "future hardening"
|
||
follow-up for a check whose trigger you just certified absent. A one-line note
|
||
in the Checks Performed row is the maximum; project-wide hardening
|
||
recommendations go in §5.
|
||
- **Every real issue gets a `F-NN` ID in §2.** No "asides" / "notes" / "things
|
||
to watch" section — an implementing agent will skip it. If you can describe
|
||
the bug, you can write a numbered entry for it.
|
||
- **`Fix:` is one pasteable thing.** Not a menu, not "and similar editors
|
||
elsewhere", not "ask the team". If the right call genuinely depends on a team
|
||
choice, pick the safer default and note the alternative in one line. Enumerate
|
||
every affected location by file:line — "and others" is not actionable.
|
||
|
||
### 1. Summary
|
||
|
||
One paragraph + a per-category count (A / B / C / D / E). Count **distinct
|
||
findings** (a consolidated systemic finding is one), so totals equal §2 entries.
|
||
State which panels / directories you scanned.
|
||
|
||
### 2. Findings
|
||
|
||
Grouped by check category (A. Access Control / B. File Uploads & RCE / C. XSS &
|
||
Injection / D. Query Scoping & Data Exposure / E. Dependencies). Inside a
|
||
category, order by check ID then by location. Required shape:
|
||
|
||
```
|
||
|
||
### [F-01] Inline ToggleColumn on `is_admin` bypasses the update policy
|
||
|
||
Check: A4
|
||
Location: app/Filament/Resources/Users/Tables/UsersTable.php:42
|
||
Component: Filament\Tables\Columns\ToggleColumn
|
||
Docs: https://filamentphp.com/docs/5.x/tables/columns/toggle#authorization
|
||
|
||
Issue: The `is_admin` ToggleColumn is editable inline. Inline columns don't
|
||
run model policies — only `->disabled()`. Any user who can see the row can
|
||
toggle admin status via Livewire.
|
||
|
||
Fix: ->disabled(fn (User $record): bool => ! auth()->user()->can('update', $record))
|
||
|
||
Verify: Test a non-admin user cannot update the column (see Recommended Tests).
|
||
```
|
||
|
||
Stable ID (`F-NN`), the catalogue check ID, `file:line`, full namespace, docs
|
||
URL, issue, pasteable fix, verify.
|
||
|
||
### 3. Checks Performed
|
||
|
||
A table of every catalogue check with `Finding` / `Pass` / `N/A`. One-line
|
||
reason for each `N/A`.
|
||
|
||
### 4. Recommended Tests
|
||
|
||
Tests covering the confirmed findings, using Filament's helpers
|
||
(https://filamentphp.com/docs/5.x/testing/overview).
|
||
|
||
### 5. Optional Hardening Tips
|
||
|
||
Project-wide configuration knobs that aren't a §2 finding today but reduce
|
||
regression risk. Strict rules:
|
||
|
||
- **Only project-wide configuration.** No file:line. Anything pointing at a
|
||
specific location is a §2 finding instead.
|
||
- **Trigger condition required.** Each tip names the verified condition that
|
||
made it relevant (e.g. "5 non-Spatie private FileUpload fields exist").
|
||
- **Omit §5 entirely when empty** — don't stub it as "no hardening tips needed."
|
||
|
||
This is not an "asides" escape hatch — see the §2 rules. Real issues belong in
|
||
§2.
|
||
|
||
### When to ask vs proceed
|
||
|
||
- **Proceed** by default — this skill produces a plan, not edits.
|
||
- **Ask** only when intent is ambiguous _and_ changes the verdict (e.g. a
|
||
resource that may be intentionally open). State your assumption and continue.
|
||
|
||
# Security Checks Catalogue
|
||
|
||
Every Filament v5 security notice, grouped by category (A–E). Each entry lists
|
||
**Search**, **Flag if**, **Fix**, **Docs**, with **Why** / **Safe when** /
|
||
**Exceptions** where useful. Reference:
|
||
https://filamentphp.com/docs/5.x/advanced/security
|
||
|
||
## A. Access Control
|
||
|
||
### A1. Bulk delete/restore missing the `*Any()` policy guard — `[Seed]` `[Conditional]`
|
||
|
||
A _missing_ policy is **not** a finding. This is only the narrow inconsistency
|
||
where a per-record guard exists but the matching bulk guard does not.
|
||
|
||
- **Search**: candidate policies —
|
||
`grep -rnE "function (delete|forceDelete|restore)\(" app/Policies` — keeping
|
||
only those whose body does real work (references `$record`, `$user`, `Gate`,
|
||
or `->can(`, not a bare `return true;`). Then confirm a matching bulk action
|
||
exists —
|
||
`grep -rnE "DeleteBulkAction|ForceDeleteBulkAction|RestoreBulkAction" app/Filament`
|
||
(including the default group) — and whether the guard is defined:
|
||
`grep -rnE "function (deleteAny|forceDeleteAny|restoreAny)\(" app/Policies`.
|
||
- **Flag if**: the per-record method does real work **and** a matching bulk
|
||
action exists **but** the policy has no corresponding `deleteAny()` /
|
||
`forceDeleteAny()` / `restoreAny()`.
|
||
- **Fix**: record-independent check (role/permission gate) → copy the same logic
|
||
into the `*Any()` method. Record-dependent check (ownership) → add
|
||
`->authorizeIndividualRecords('delete')` (resp. `'forceDelete'`, `'restore'`)
|
||
to the bulk action so Filament re-checks per record.
|
||
- **Why**: bulk actions authorize the whole batch once against `*Any()`, never
|
||
per-record — a missing `*Any()` fails open. (With the panel's
|
||
`->strictAuthorization()` setting enabled a missing `*Any()` throws instead —
|
||
N/A there.)
|
||
- **Docs**:
|
||
- https://filamentphp.com/docs/5.x/resources/deleting-records#authorization
|
||
- https://filamentphp.com/docs/5.x/actions/delete#improving-the-performance-of-delete-bulk-actions
|
||
|
||
### A2. Import bypasses the `create()` / `update()` policy — `[Seed]` `[Conditional]`
|
||
|
||
Anchored on inconsistency (like [A1]): a model with a meaningful `create()` /
|
||
`update()` policy while an importer writes records with no equivalent
|
||
authorization. A _missing_ policy is not the trigger.
|
||
|
||
- **Search**: `grep -rn "ImportAction::make" app` and open the referenced
|
||
importer. For completeness, `grep "extends Importer"` catches imports run
|
||
outside `ImportAction` (commands, queued re-runs).
|
||
- **Flag if**: the model's policy has a real `create()` / `update()` **but** the
|
||
importer contains no `can(` / `Gate::` / `authorize(` / `abort` check in any
|
||
of its overridable hooks (`resolveRecord()`, `beforeValidate`, `beforeFill`,
|
||
`beforeSave`, `beforeCreate`, `beforeUpdate`) — authorizing in any one is
|
||
safe.
|
||
- **Fix**: record-independent → copy the check into the hook; record-dependent
|
||
(update) → `abort_unless(auth()->user()->can('update', $this->record), 403)`;
|
||
new records (create) →
|
||
`abort_unless(auth()->user()->can('create', static::getModel()), 403)` in
|
||
`beforeCreate()` (the record is filled-but-unsaved there).
|
||
- **Why**: `ImportAction` resolves, fills, and saves each CSV row without
|
||
consulting Laravel policies.
|
||
- **Docs**:
|
||
https://filamentphp.com/docs/5.x/actions/import#per-record-authorization
|
||
|
||
### A3. Overridden `can*()` methods no longer invoked (v4+) — `[Site]` `[Conditional]`
|
||
|
||
- **Search**:
|
||
`grep -rnE "function can(Create|Edit|View|ViewAny|Delete|DeleteAny|ForceDelete|ForceDeleteAny|Restore|RestoreAny|Reorder|Replicate|Attach|Detach|DetachAny|Associate|Dissociate|DissociateAny)\(" app/Filament`.
|
||
- **Flag if**: an override shows authorization intent in its body — references
|
||
`auth()`, `$user`, `Gate`, `->can(`, or `abort` — and that rule is not also
|
||
enforced by a policy. (Skip static returns with no such signal.)
|
||
- **Fix**: move the logic into the model policy, or override the matching
|
||
`get*AuthorizationResponse()` method (which must return an
|
||
`Illuminate\Auth\Access\Response` — `Response::allow()` / `Response::deny()` —
|
||
not a bool).
|
||
- **Why**: in v4+ `can*()` still gates page access, navigation, and global
|
||
search, but record/bulk **actions** and relation managers authorize via
|
||
`get*AuthorizationResponse()` directly — so the page looks gated while the
|
||
action leaks.
|
||
- **Docs**:
|
||
https://filamentphp.com/docs/5.x/upgrade-guide#overriding-the-can-authorization-methods-on-a-resource-relationmanager-or-managerelatedrecords-class
|
||
|
||
### A4. Inline editable columns bypass the `update()` policy — `[Site]` `[Conditional]`
|
||
|
||
Anchored on inconsistency: a model whose policy does real `update()` work while
|
||
an inline column saves to it without an equivalent guard.
|
||
|
||
- **Search**: `grep -rnE "(Select|Toggle|TextInput|Checkbox)Column::make" app`
|
||
across all source roots. If tables build columns from custom subclasses, also
|
||
`grep -rnE "extends (Toggle|Select|TextInput|Checkbox)Column" app`.
|
||
- **Flag if**: the model policy has a meaningful `update()` **and** the column
|
||
has no `->disabled()` closure carrying an auth check. Static
|
||
`->disabled(true)` and `->rules(...)` (validation, not authorization) do not
|
||
count.
|
||
- **Safe when**: no meaningful `update()` policy (mark `N/A`), or the column
|
||
carries its own auth inside `->updateStateUsing()` / `->beforeStateUpdated()`.
|
||
- **Fix**: record-independent →
|
||
`->disabled(fn (): bool => ! auth()->user()->can('update_posts'))`;
|
||
record-dependent →
|
||
`->disabled(fn ($record): bool => ! auth()->user()->can('update', $record))`;
|
||
or move the field to a policy-gated Edit page / modal action.
|
||
- **Why**: these columns save via a Livewire request that checks only
|
||
`->disabled()` (and field validation) — never the `update()` policy — so any
|
||
user who can see the row can write the value.
|
||
- **Docs**:
|
||
- https://filamentphp.com/docs/5.x/tables/columns/toggle#authorization
|
||
- https://filamentphp.com/docs/5.x/tables/columns/text-input#authorization
|
||
- https://filamentphp.com/docs/5.x/tables/columns/select#authorization
|
||
- https://filamentphp.com/docs/5.x/tables/columns/checkbox#authorization
|
||
|
||
### A5. Livewire upload RPC on components without an upload field — `[Seed]` `[Conditional]`
|
||
|
||
- **Search**:
|
||
`grep -rlE "InteractsWith(Schemas|Forms|Infolists|Actions|Table)" app`,
|
||
excluding classes that extend Filament's `Resource` / `Page` /
|
||
`RelationManager`. Then confirm each hit composes `InteractsWithSchemas`
|
||
directly, or via `InteractsWithForms` (which itself composes it) — only those
|
||
components expose the upload RPC. A class that only uses `InteractsWithTable`
|
||
/ `InteractsWithActions` does not, so it is not a target.
|
||
- **Flag if**: a custom component is reachable by untrusted users (check route
|
||
middleware, or whether it's rendered on a public Blade view) and lacks
|
||
`RestrictsFileUploadsToSchemaComponents`. Chief cases: unauthenticated pages,
|
||
or components whose schema has no upload field.
|
||
- **Fix**: add
|
||
`Filament\Schemas\Concerns\RestrictsFileUploadsToSchemaComponents` to the
|
||
component class (which must implement `HasSchemas` / `HasForms`); it 403s
|
||
uploads whose target isn't an upload field in the component's schema.
|
||
- **Why**: `InteractsWithSchemas` composes Livewire's `WithFileUploads`,
|
||
exposing `_startUpload` / `_finishUpload` everywhere. Panel resources/pages
|
||
re-authorize every request, so the trait isn't needed there.
|
||
- **Docs**:
|
||
https://filamentphp.com/docs/5.x/advanced/security#restricting-livewire-file-uploads-to-schema-components
|
||
|
||
### A6. Custom Livewire: work runs before authorization — `[Seed]` `[Conditional]`
|
||
|
||
- **Search**: the vulnerable construct is a lifecycle hook that runs before the
|
||
auth check, so anchor on the hooks themselves —
|
||
`grep -rnE "function (boot|mount|hydrate)" app` (catches `boot()`,
|
||
`boot{Trait}()`, the `mount()` body, and per-property `hydrate{Prop}()`) — in
|
||
custom Filament `Page` classes and standalone Livewire components. (A resource
|
||
page is only a target if it adds sensitive work _above_ its own
|
||
`authorizeAccess()` call.)
|
||
- **Flag if**: a `boot()` / `boot{Trait}()` body, a custom page's `mount()`
|
||
body, or a bare `hydrate()` hook performs sensitive side effects (DB writes,
|
||
event dispatch, external calls) not preceded by an authorization check. (A
|
||
per-property `hydrate{Prop}()` hook runs just _after_ the auth check — flag it
|
||
only as defence-in-depth.)
|
||
- **Fix**: do the work _after_ authorization has fired — in the `mount()` body
|
||
below an explicit `$this->authorizeAccess()` (resource pages) /
|
||
`abort_unless(static::canAccess(), 403)` (custom pages) call, or in a
|
||
`wire:click` action method (always post-authorization). Avoid sensitive work
|
||
in `boot()` or per-property hydrate hooks.
|
||
- **Why**: Filament wires page authorization into Livewire _trait_ hooks —
|
||
`CanAuthorizeAccess` on custom pages, `mountCanAuthorizeResourceAccess()` /
|
||
`hydrateCanAuthorizeResourceAccess()` on resource pages — which fire _after_
|
||
the component's own `boot()` / `mount()` / bare `hydrate()` body. So side
|
||
effects in those hooks run even when the request is ultimately aborted 403.
|
||
Resource pages call `authorizeAccess()` explicitly inside `mount()`, so only
|
||
code _above_ that line is exposed.
|
||
- **Docs**:
|
||
https://filamentphp.com/docs/5.x/advanced/security#authorization-and-the-livewire-request-lifecycle
|
||
|
||
## B. File Uploads & RCE
|
||
|
||
### B1. Path tampering on shared disks (`FileUpload` + `RichEditor`) — `[Seed]` `[Conditional]`
|
||
|
||
On a shared private disk, every unprotected writer of either type is an
|
||
exfiltration primitive — a user can tamper their own field to read content other
|
||
users uploaded through other fields on the same disk. Provider-backed fields
|
||
(Spatie `FileUpload` subclass, provider-backed `RichEditor`) are safe
|
||
**writers**, but their content remains a potential **target**: a tampered
|
||
non-provider field can read provider-backed content if its path is known.
|
||
|
||
- **Search**:
|
||
1. **Group all upload-storing fields by resolved disk** —
|
||
`grep -rnE "(FileUpload|SpatieMediaLibraryFileUpload)::make" app` and
|
||
`grep -rn "RichEditor::make" app`, resolving each field's disk via
|
||
`->disk(...)` or `->fileAttachmentsDisk(...)` → panel default →
|
||
`config('filament.default_filesystem_disk')` → `FILESYSTEM_DISK`. Drop:
|
||
- Public / web-served disks (already addressable — no escalation).
|
||
- Disks single-user / single-tenant **by infrastructure** — Flysystem
|
||
`root` bound per tenant at framework level: static in
|
||
`config/filesystems.php`, dynamic via
|
||
`Storage::set('uploads', ['root' => "/tenants/{$tenantId}"])` in a
|
||
service provider / middleware, or a tenancy package (Spatie
|
||
multi-tenancy, Stancl/Tenancy). App-layer prefixing on a shared root does
|
||
NOT count — tampering bypasses string prefixes.
|
||
|
||
2. **Find disclosure targets per disk** — any file on the disk root worth
|
||
exfiltrating, regardless of which mechanism uploaded it:
|
||
- **Sensitive content** in FileUpload / SpatieMediaLibraryFileUpload fields
|
||
or RichEditor image attachments. Judgement; ask the user when unclear.
|
||
Reliable name signals (apply to the field's or editor's hosting model):
|
||
`Medical*`, `Health*`, `Patient*`, `Tax*`, `Bank*`, `Invoice*`,
|
||
`Statement*`, `Identity*`, `Passport*`, `Credential*`, `Token*`,
|
||
`Secret*`. Generic names like `Document` / `Attachment` / `File` /
|
||
`Upload` are **not** signals on their own — context determines
|
||
sensitivity (check `composer.json` / `.env` for HIPAA / PCI / PII hints,
|
||
and the field's actual domain use).
|
||
- **Enumerable filenames** —
|
||
`grep -rn "preserveFilenames\|getUploadedFileNameForStorageUsing"` across
|
||
both field types. Spatie / UUID-scoped randomness is the safe case;
|
||
preserved or deterministic names are targets.
|
||
- **Non-Filament content** on the disk root — generated PDFs, queued
|
||
exports, log dumps written by other code paths.
|
||
|
||
3. **Find unprotected writers per disk** — two sub-categories:
|
||
- **FileUpload writers**: non-Spatie `FileUpload` fields without
|
||
`preventFilePathTampering(true)` (per-field, or via global
|
||
`FileUpload::configureUsing(...)` default).
|
||
|
||
- **RichEditor writers**: editors satisfying all three of:
|
||
- **Accepts attachments** — default toolbar includes `attachFiles`; a
|
||
custom toolbar (`->toolbarButtons([...])`) that omits it disables
|
||
attachment uploads, so the editor can't insert `<img data-id>` nodes.
|
||
Skip.
|
||
- **No UUID-scoped provider** — trace the editor to its hosting model and
|
||
check `registerRichContent(..., ...)->fileAttachmentProvider(...)` in
|
||
the model file (`grep -rn "fileAttachmentProvider(" app/Models`
|
||
enumerates registered providers). Spatie's `MediaLibrary` or any custom
|
||
provider that re-validates ownership protects the editor. **Action /
|
||
page schemas with no model backing have nowhere to register a
|
||
provider** — always unprotected.
|
||
- **No tampering protection** — no
|
||
`->preventFileAttachmentPathTampering(true)` on the field, and no
|
||
global `RichEditor::configureUsing(...)` default.
|
||
|
||
4. **Gradient or audience check** (per disk, cheap heuristic) — either flags
|
||
an unprotected writer:
|
||
- **Gradient** — any target's gating policy (`view` / `download` /
|
||
equivalent) references `$record` or `$user` _in the method body_
|
||
(ownership scoping creates per-record asymmetry). Read the body; don't
|
||
infer from the signature alone — `view(User $user, Article $record)` may
|
||
never touch `$record`.
|
||
- **Audience** — the rendered field output reaches viewers beyond the
|
||
writer's access scope: an avatar shown on a public profile page or in a
|
||
staff list, an editor body rendered into a public article or into a
|
||
notification email. Even with flat permissions, a publicly rendered
|
||
tampered preview or `<img>` fetches the file without re-authenticating
|
||
the viewer.
|
||
|
||
5. **Identify legitimate fill sources per flagged writer** — for each writer
|
||
surviving steps 3 and 4, find every place its value can be set to a path
|
||
that did NOT come from a fresh upload or the record's original. These
|
||
become **mandatory** `allowFilePathUsing:` exclusions in the fix — applying
|
||
the global default without them breaks those workflows. Sources to inspect:
|
||
- **Field defaults** — `->default(...)` setting a path (FileUpload) or HTML
|
||
containing `<img data-id="...">` (RichEditor).
|
||
- **Form fill hooks** — `mutateFormDataBeforeFill()` (page),
|
||
`mutateRecordDataUsing()` (modal action), explicit `fillForm()` /
|
||
`$this->form->fill(...)` calls.
|
||
- **Action fills** — `Action::make(...)->action(...)` closures that call
|
||
`$set('<field>', ...)` or otherwise write the field from a template /
|
||
another record. Grep `->set('<field>'` in panels/pages hosting the
|
||
writer.
|
||
- **Reactive / live updates** — `->afterStateUpdated(...)` or `->live()`
|
||
callbacks writing to the field from elsewhere.
|
||
|
||
Encode each allowed pattern in an `allowFilePathUsing:` closure (e.g.
|
||
`str_starts_with($file, 'templates/')` for a template directory; a
|
||
membership check on a specific Spatie media collection used by templates).
|
||
|
||
- **Flag if**: a disk has targets, unprotected writers (of either type), and a
|
||
gradient or broader audience. One §2 entry per disk, listing every unprotected
|
||
writer and the targets they could reach.
|
||
|
||
- **Safe when**: no targets; no unprotected writers; no gradient AND no broader
|
||
audience.
|
||
|
||
- **Fix** (one §2 entry per disk):
|
||
1. Add the relevant global default(s) —
|
||
`FileUpload::configureUsing(fn (FileUpload $component) => $component->preventFilePathTampering())`
|
||
and/or
|
||
`RichEditor::configureUsing(fn (RichEditor $component) => $component->preventFileAttachmentPathTampering())`.
|
||
2. For **every** fill source from step 5, add a per-field exclusion:
|
||
`->preventFilePathTampering(allowFilePathUsing: fn (string $file): bool => str_starts_with($file, 'templates/'))`
|
||
(or its RichEditor equivalent). Enumerate each with file:line and the
|
||
specific allowed pattern.
|
||
|
||
**Step 2 is mandatory when step 5 found fill sources** — applying the global
|
||
default alone will break fill-from-template / copy-from-another-record /
|
||
default-attachment workflows in production. Alternative when only a small
|
||
subset of fields is affected: apply the per-field method (with the exclusion
|
||
in the same call) instead of registering a global default.
|
||
|
||
- **§5 tip**: if a disk has any non-provider writer and the corresponding global
|
||
default is missing
|
||
(`grep -rn "preventFilePathTampering\|preventFileAttachmentPathTampering" app/Providers`),
|
||
the missing default(s) belong in §5 — defends against a future field creating
|
||
the gap, even when no §2 finding fires today.
|
||
|
||
- **Why**: two mechanisms with the same flaw — both ask the disk for whatever
|
||
path the client supplied.
|
||
- **FileUpload**: Livewire state holds a client-controlled path. Preview /
|
||
download URL methods read state on every render — tampering redirects them
|
||
to any file under the disk root, persistence not required (so
|
||
`storeFiles(false)` is **not** an exemption: the state is still there and
|
||
the URL methods still read it). `->preventFilePathTampering()` validates
|
||
against the record's original value or a fresh upload; off by default.
|
||
- **RichEditor**: image attachment `<img data-id="...">` is client-controlled.
|
||
The editor rewrites each `data-id` into an `<img src>` URL at render time,
|
||
signing whatever path it contains without checking ownership — tampering
|
||
redirects the rendered image to any file under the disk root.
|
||
`->preventFileAttachmentPathTampering()` validates the `data-id`; off by
|
||
default. UUID-scoped attachment providers (Spatie's
|
||
`fileAttachmentProvider(MediaLibrary)`, or any custom provider that
|
||
re-validates ownership) protect by rejecting non-owned paths.
|
||
|
||
- **Docs**:
|
||
- https://filamentphp.com/docs/5.x/forms/file-upload#authorizing-existing-file-paths
|
||
- https://filamentphp.com/docs/5.x/forms/rich-editor#securing-file-attachment-ids
|
||
|
||
### B2. Upload field accepts any file type — `[Site]` `[Conditional]`
|
||
|
||
Without an explicit accepted-type allowlist, `FileUpload` accepts any file. Add
|
||
a per-field restriction to every upload — `->acceptedFileTypes([...])`, or the
|
||
shortcuts `->image()` / `->avatar()` — so renamed `.php` uploads (whose body is
|
||
plain PHP text) are detected by content-sniffing and rejected.
|
||
|
||
- **Search**: `grep -rnE "(FileUpload|SpatieMediaLibraryFileUpload)::make" app`.
|
||
For each field, check whether `->acceptedFileTypes(`, `->image(`, or
|
||
`->avatar(` is set.
|
||
|
||
- **Flag if**: a field has no type restriction. An overly-broad list like
|
||
`application/*` is also a finding — the `mimetypes` rule is an _allowlist_
|
||
matched against the file's sniffed MIME, so broad wildcards admit dangerous
|
||
types (`application/x-php`, etc.).
|
||
|
||
- **Safe when**: the field already restricts via `->image()` / `->avatar()` /
|
||
`->acceptedFileTypes([...])`.
|
||
|
||
- **Fix** (one §2 entry listing every unrestricted field): add
|
||
`->acceptedFileTypes([...])` per field with the appropriate type list, or use
|
||
`->image()` / `->avatar()` when applicable. Enumerate every affected field
|
||
with file:line.
|
||
|
||
- **Why**: `FileUpload` accepts any type by default. `->acceptedFileTypes(...)`
|
||
activates Laravel's `mimetypes` rule, which content-sniffs the uploaded file
|
||
via finfo — the client-supplied Content-Type header is ignored and the
|
||
filename extension is never validated. A renamed `.php` file (raw PHP text)
|
||
sniffs as `text/x-php` and fails an `image/*` allowlist. A **polyglot** file
|
||
(valid image magic bytes + embedded PHP) sniffs as an image and passes — it is
|
||
stopped only when paired with [B3] (random storage filenames keep the `.php`
|
||
extension off disk). Real-world impact tracks the field's resolved disk
|
||
(`->disk(...)` → panel default → `config('filament.default_filesystem_disk')`
|
||
→ `FILESYSTEM_DISK`):
|
||
- **Web-served disk** (`public` with `storage:link`, or anything Apache /
|
||
Nginx executes from): unrestricted upload → renamed `.php` lands on disk
|
||
with an executable extension → RCE. (B2 alone defeats the renamed-PHP
|
||
attack; [B3] closes the polyglot path.)
|
||
- **Non-served disk** (`s3` / `gcs` / private cloud storage — the production
|
||
default for most Filament apps): no execution path; missing restriction is
|
||
hygiene only, not an exploit. Still worth flagging — a future field added to
|
||
a different disk inherits the unrestricted pattern.
|
||
|
||
- **Docs**:
|
||
https://filamentphp.com/docs/5.x/forms/file-upload#file-type-validation
|
||
|
||
### B3. User-controlled file names → remote code execution — `[Site]` `[Conditional]`
|
||
|
||
- **Search**:
|
||
`grep -rn "preserveFilenames\|getUploadedFileNameForStorageUsing" app`.
|
||
- **Flag if**: used together with a `local`/`public` disk — resolve the disk in
|
||
order: the field's `->disk(...)`, then the panel's
|
||
`->defaultFilesystemDisk()`, then `config('filament.default_filesystem_disk')`
|
||
(defaults to `FILESYSTEM_DISK` → `local`). `->storeFileNamesIn(` is the safe
|
||
pattern.
|
||
- **Safe when**: the field targets a non-served disk (e.g. `->disk('s3')`), or
|
||
uses `->storeFileNamesIn(` → mark `Pass`. A `local`/`public` disk not actually
|
||
web-served has no execution path either; confirm HTTP reachability before
|
||
flagging. Filename-collision concerns on a non-public disk belong under [B1],
|
||
not here.
|
||
- **Fix**: keep random storage names; store the original with
|
||
`->storeFileNamesIn('column')` instead of preserving it on disk.
|
||
- **Why**: preserving the client filename keeps a `.php` extension on disk;
|
||
[B2]'s `mimetypes` rule content-sniffs the body but never checks the
|
||
extension, so a **polyglot** upload (image magic bytes + embedded PHP) passes
|
||
an `image/*` allowlist while landing as `something.php` — executable code on a
|
||
PHP-served disk → RCE. Both halves of the chain are needed: B2 alone stops the
|
||
renamed-only `.php`; B3 alone keeps the extension dangerous; closing one
|
||
closes the gap.
|
||
- **Docs**:
|
||
https://filamentphp.com/docs/5.x/forms/file-upload#security-implications-of-controlling-file-names
|
||
|
||
## C. XSS & Injection
|
||
|
||
Every C-check has two halves — **source** (the interpolated value) and **sink**
|
||
(the renderer). Verify both before flagging:
|
||
|
||
- **Source must be user-controllable.** Trace each interpolated variable one hop
|
||
to its assignment and name the origin in the §2 entry. An enum label
|
||
(`$preset->getLabel()`), a hardcoded map, or `__('...')` is not user input →
|
||
`Pass`.
|
||
- **Sink must render raw.** Several Filament paths sanitise downstream
|
||
(`Notification` title/body via `str(...)->sanitizeHtml()`; `->html()` on
|
||
columns/entries; `RichContentRenderer::toHtml()`). Others don't (action
|
||
`modalDescription`, `TextEntry::html()` fed a pre-built `Htmlable`, raw
|
||
`{!! !!}` in Blade, mail/notification views). Sanitised sink → `Pass`.
|
||
|
||
### C1. Unsanitized rich-editor / markdown output in Blade — `[Seed]` `[Conditional]`
|
||
|
||
- **Search**: two steps. First list the editor-backed attribute names —
|
||
`grep -rnoE "(RichEditor|MarkdownEditor)::make\(\s*[\"'][^\"']+[\"']\)" app`
|
||
(both quote styles). Then grep for raw echoes —
|
||
`grep -rnE "\{!!" resources/views` plus any mail / notification / PDF view
|
||
roots (e.g. `resources/views/mail`, `resources/views/notifications`) — and
|
||
match them against those names. (Aliased access —
|
||
`$body = $record->content; {!! $body !!}` — slips this heuristic; spot-check.)
|
||
- **Flag if**: a `{!! !!}` echoes one of those editor-backed attributes raw
|
||
(without `->sanitizeHtml()`).
|
||
- **Fix**: `{!! str($record->content)->sanitizeHtml() !!}` (Markdown:
|
||
`{!! str($record->content)->markdown()->sanitizeHtml() !!}`). If the editor
|
||
uses `->json()`, render with
|
||
`Filament\Forms\Components\RichEditor\RichContentRenderer::make($record->content)->toHtml()`
|
||
(it sanitizes) — `sanitizeHtml()` on raw JSON renders nothing.
|
||
- **Why**: editor content is raw user HTML. Filament's own renderers
|
||
auto-sanitise, so only your own raw echoes are at risk.
|
||
- **Docs**:
|
||
- https://filamentphp.com/docs/5.x/forms/rich-editor#security
|
||
- https://filamentphp.com/docs/5.x/forms/markdown-editor#security
|
||
|
||
### C2. Raw HTML bypasses the sanitizer (`HtmlString` / `view()`) — `[Site]` `[Conditional]`
|
||
|
||
- **Search**:
|
||
`grep -rnE "new HtmlString\(|->toHtmlString\(\)" app resources/views`, plus
|
||
`formatStateUsing(` / `->state(` / `getStateUsing(` returning a `view()` or
|
||
`HtmlString`, plus `TextEntry|TextColumn::make(...)->html(` where the state is
|
||
built upstream from an `HtmlString` interpolation. `->html()` is safe only
|
||
when the upstream state-builder isn't injecting unescaped data — if it is, the
|
||
finding sits at the interpolation site, not the `->html()` call.
|
||
- **Flag if**: user-controlled data is interpolated **unescaped** into the raw
|
||
HTML _and_ the sink renders raw (see C-category intro). Static markup, or
|
||
output where every dynamic value is `e()`'d, is N/A.
|
||
- **Sink classification**:
|
||
- `Notification::title/body` sanitises downstream → **no finding** (residual
|
||
risk is broken HTML from attributes like `O'Brien` in an `href` — surface as
|
||
a §5 escape-interpolated-values tip if the pattern is widespread).
|
||
- Action `modalDescription/Heading`, `TextEntry/TextColumn::html()` fed a
|
||
pre-built `Htmlable`, raw `{!! !!}` in a Blade/mail/notification view — no
|
||
downstream sanitiser → **finding**.
|
||
- A custom view that calls `->sanitizeHtml()` or
|
||
`RichContentRenderer::toHtml()` on the value before echoing → **Pass**.
|
||
- **Fix**: prefer `->html()` (when its state isn't already pre-built raw HTML);
|
||
otherwise `e()` every dynamic value before wrapping `HtmlString`. Symfony's
|
||
`HtmlSanitizer` default permits inline `style` — configure a stricter
|
||
sanitizer for fully untrusted content.
|
||
- **Docs**:
|
||
- https://filamentphp.com/docs/5.x/tables/columns/text#rendering-raw-html-without-sanitization
|
||
- https://filamentphp.com/docs/5.x/infolists/text-entry#rendering-raw-html-without-sanitization
|
||
- https://filamentphp.com/docs/5.x/schemas/primes#text-component
|
||
|
||
### C3. Unsafe URL schemes in `url()` — `[Site]` `[Conditional]`
|
||
|
||
- **Search**: `grep -rnE "->(url|recordUrl)\(" app` across columns, entries,
|
||
actions, notification actions, mention providers, and `recordUrl()`. Narrow to
|
||
the risky shape (closure or raw attribute, not `route(...)`):
|
||
`grep -rnE "->(url|recordUrl)\(\s*fn|->(url|recordUrl)\([^)]*\\\$record->" app`.
|
||
- **Flag if**: any part of the URL derives from user-controlled data (e.g. a
|
||
closure returning a raw model attribute). `route(...)` URLs are safe.
|
||
Input-side validation (`->url()` / `->email()`) on the form field is **not** a
|
||
`Pass` — the stored value can still carry `javascript:` / `data:` (via import,
|
||
seeder, direct write) and is re-emitted unsanitised.
|
||
- **Fix**: wrap in `Str::sanitizeUrl($value)` (a Filament macro on
|
||
`Illuminate\Support\Str`, also `str($value)->sanitizeUrl()`; returns the value
|
||
only for schemeless or `http`/`https` URLs, else `null`); pass extra schemes
|
||
via `allowedSchemes:`.
|
||
- **Why**: a `javascript:` or `data:` value renders into an `<a href>` and
|
||
executes on click → XSS.
|
||
- **Docs**:
|
||
https://filamentphp.com/docs/5.x/advanced/security#validating-user-input
|
||
|
||
### C4. Unescaped HTML in option labels (`allowHtml` / `allowOptionsHtml`) — `[Site]` `[Conditional]`
|
||
|
||
- **Search**: `grep -rnE "->(allowHtml|allowOptionsHtml)\(" app` (the trailing
|
||
`(` avoids matching `allowHtmlValidationMessages`, which is [C6]; also covers
|
||
`CheckboxList` / `MorphToSelect`, which share the `allowHtml` flag).
|
||
- **Flag if**: the option labels derive from user/DB data (a relationship title,
|
||
a user-entered name). Static developer-authored labels are N/A.
|
||
- **Fix**: remove the flag if labels needn't be HTML; otherwise escape any
|
||
dynamic value with `e()` before it reaches the label.
|
||
- **Docs**:
|
||
- https://filamentphp.com/docs/5.x/forms/select#allowing-html-in-the-option-labels
|
||
- https://filamentphp.com/docs/5.x/tables/columns/select#allowing-html-in-the-option-labels
|
||
|
||
### C5. Unescaped `extraAttributes()` values — `[Site]` `[Conditional]`
|
||
|
||
- **Search**: `grep -rnE "extra[A-Za-z]*Attributes\(" app`.
|
||
- **Flag if**: attribute names/values are built from user-controlled data.
|
||
Static arrays (class lists, Alpine/Livewire directives) are N/A.
|
||
- **Fix**: pass only trusted/validated data; if a value must be dynamic, escape
|
||
it with `e($value)` before adding it to the attribute array, and never build
|
||
attribute _names_ from user input.
|
||
- **Why**: `extra*Attributes()` render values into HTML without escaping by
|
||
design (to allow Alpine/Livewire directives), so user data can break out of
|
||
the attribute.
|
||
- **Docs**:
|
||
https://filamentphp.com/docs/5.x/advanced/security#validating-user-input
|
||
|
||
### C6. Unescaped validation messages (`allowHtmlValidationMessages`) — `[Site]` `[Conditional]`
|
||
|
||
- **Search**: `grep -rn "allowHtmlValidationMessages" app`.
|
||
- **Flag if**: a message interpolates user-controlled data, uses a Laravel
|
||
placeholder that echoes input (`:input`, `:value`), or relies on an HTML /
|
||
user-derived field label (`:attribute`). Developer-authored and
|
||
translation-file messages with no such interpolation are N/A.
|
||
- **Fix**: ensure no message interpolates unescaped user data, or remove the
|
||
call.
|
||
- **Docs**:
|
||
https://filamentphp.com/docs/5.x/forms/validation#allowing-html-in-validation-messages
|
||
|
||
### C7. User input in client-side JS expressions — `[Site]` `[Conditional]`
|
||
|
||
- **Search**:
|
||
`grep -rnE "hiddenJs\(|visibleJs\(|afterStateUpdatedJs\(|actionJs\(|alpineClickHandler\(|JsContent::make\(|->js\(\)" app`.
|
||
- **Flag if**: user input is spliced into the JS string via PHP
|
||
interpolation/concatenation. Runtime reads via `$state` / `$get()` are safe.
|
||
- **Fix**: use `$get()` / `$state` for dynamic values; never interpolate user
|
||
input PHP-side.
|
||
- **Why**: these strings are `eval()`'d client-side, so PHP-side interpolation
|
||
of user data → XSS.
|
||
- **Docs**:
|
||
- https://filamentphp.com/docs/5.x/forms/overview#hiding-a-field-using-javascript
|
||
- https://filamentphp.com/docs/5.x/actions/overview#running-javascript-when-an-action-is-clicked
|
||
|
||
## D. Query Scoping, Data Exposure & Multi-Tenancy
|
||
|
||
### D1. List/widget query ignores an ownership rule enforced elsewhere — `[Seed]` `[Conditional]`
|
||
|
||
Anchored on inconsistency, not "scope everything": flag a query that returns
|
||
records a per-user ownership rule — visible elsewhere — should have excluded.
|
||
(Tenant scoping is [D3].) A scope that is **registered but logically broken**
|
||
counts as a D1 finding too — applying a broken scope is the same failure mode as
|
||
not applying one.
|
||
|
||
- **Search**: seed = models with an owner FK
|
||
(`grep -rnE "(user|author|owner|account|customer)_id|created_by" database/migrations app/Models`)
|
||
or a record-dependent `view()` policy
|
||
(`grep -rnE "function view\(" app/Policies` — body references `$record`). For
|
||
each, inspect query sites —
|
||
`grep -rnE "getEloquentQuery|modifyQueryUsing|getTableQuery|getStats|getData" app/Filament`
|
||
— and registered scopes (`#[ScopedBy(...)]`, `addGlobalScope` in `booted()`,
|
||
classes under `app/Models/Scopes`, `app-modules/*/src/Models/Scopes`).
|
||
- **Flag if**: (a) the query doesn't apply the ownership scope the policy/FK
|
||
implies, or (b) a query-customisation site (`getEloquentQuery()` override,
|
||
`->modifyQueryUsing(...)`, a filter `query(...)` callback) calls a top-level
|
||
`->where(...)->orWhere(...)` not wrapped in `->where(function ($q) { ... })` —
|
||
Filament appends search/filter constraints _after_ your callback, so the
|
||
top-level `orWhere` escapes the surrounding `AND` group and leaks rows.
|
||
(Registered global scopes do **not** need this wrap: Laravel auto-groups
|
||
`or`-containing scope constraints into a nested `where`.)
|
||
- **Fix**: missing-scope → `->modifyQueryUsing(...)`, override
|
||
`getEloquentQuery()`, or add a global scope, and apply the same constraint in
|
||
widgets. Unwrapped-OR → wrap the OR pair in
|
||
`->where(function ($q) { $q->where(...)->orWhere(...); })` at the
|
||
customisation site.
|
||
- **Filter `options()` is not an access boundary** — separate search:
|
||
`grep -rnE "SelectFilter::make|SelectConstraint::make" app` and inspect each
|
||
`->options(...)` callback. **Flag if** the option list is narrowed per
|
||
user/role (`auth()` / `$user` / `Gate` / `->can(` / role check inside the
|
||
closure). The submitted value is **not** validated against the returned list
|
||
before hitting `whereIn` / `where`, so a tampered request reaches the "hidden"
|
||
rows. **Fix**: keep the full option list and enforce access in the query
|
||
(above), or wrap the filter in `->visible(...)` and gate the underlying query.
|
||
- **Docs**: https://filamentphp.com/docs/5.x/advanced/security#scoping-queries
|
||
|
||
### D2. Sensitive model attributes exposed to JavaScript — `[Seed]` `[Conditional]`
|
||
|
||
- **Search**:
|
||
`grep -rnE "(token|secret|password|api[_]?key|two_factor|ssn|tax_id|bank|iban|card_number|private_key|salary)" app/Models database/migrations`,
|
||
plus any sensitive domain column and `$appends` accessors. Restrict to models
|
||
edited via a Filament Edit/View page or modal `EditAction`/`ViewAction`.
|
||
- **Flag if**: the attribute isn't in `$hidden`, isn't excluded by a `$visible`
|
||
whitelist, and isn't stripped in `mutateFormDataBeforeFill()`.
|
||
- **Fix**: add the column to `$hidden` (covers every path), or `unset()` it in
|
||
`mutateFormDataBeforeFill(array $data): array`. For modal
|
||
`EditAction`/`ViewAction`, the scrub is `->mutateRecordDataUsing(...)`.
|
||
- **Why**: Filament exposes all non-`$hidden` attributes to JavaScript via
|
||
Livewire model binding on Edit/View pages. This is _exposure_, not
|
||
mass-assignment — only attributes with a form field are editable.
|
||
- **Docs**:
|
||
https://filamentphp.com/docs/5.x/resources/overview#protecting-model-attributes
|
||
|
||
### D3. Models/queries not auto-scoped to the tenant — `[Seed]` `[Conditional]`
|
||
|
||
- **Search**: across all source roots (unscoped queries hide in
|
||
jobs/commands/observers) —
|
||
1. `grep -rn "withoutGlobalScopes(" app` — empty-arg calls drop tenancy too.
|
||
2. Enumerate tenant-owned models by their FK
|
||
(`grep -rnE "team_id|organization_id|tenant_id|company_id" database/migrations app/Models`)
|
||
and any `BelongsToTenant`-style trait. For each, confirm it's either
|
||
exposed through a tenant-panel resource (auto-scoped) or explicitly scoped
|
||
elsewhere. Also `grep -rnE "saveQuietly\(|withoutEvents\(|unguarded\(" app`
|
||
for muted creation events.
|
||
- **Flag if**: a tenant-owned model has no resource and no explicit scope, is
|
||
queried before tenant identification (early middleware/providers) or outside
|
||
the panel, or `withoutGlobalScopes()` is called with no arguments.
|
||
- **Fix** — three mechanisms depending on context:
|
||
1. **Add a resource** for the model (simplest — resource queries are
|
||
auto-scoped via the panel).
|
||
2. **Register a model-level global scope** that filters by
|
||
`Filament::getTenant()`. Pair with
|
||
`tenantMiddleware([...], isPersistent: true)` so the tenant is
|
||
re-identified on Livewire AJAX requests (which bypass panel route
|
||
middleware); non-panel HTTP routes need the panel's tenant middleware
|
||
applied to them directly, and queue jobs need
|
||
`Filament::setTenant($tenant)` at the job's entry — persistent middleware
|
||
doesn't run on workers.
|
||
3. **Use a `creating` model listener** to populate the tenant FK on save
|
||
(covers writes without needing a query scope).
|
||
|
||
To drop a single scope without losing tenancy, use
|
||
`withoutGlobalScope(filament()->getTenancyScopeName())`. Never bare-arg
|
||
`withoutGlobalScopes()`.
|
||
|
||
- **Why**: automatic scoping applies only to models with a resource, only inside
|
||
the panel, only after tenant identification.
|
||
- **Docs**: https://filamentphp.com/docs/5.x/users/tenancy#tenancy-security
|
||
|
||
### D4. Over-permissive tenant-access methods — `[Site]` `[Conditional]`
|
||
|
||
- **Search**:
|
||
`grep -rnE "function (canAccessTenant|getTenants|canAccessPanel)\(" app`
|
||
(across all source roots — may live on a trait or a modular User model).
|
||
- **Flag if**: `canAccessTenant()` returns `true` unconditionally,
|
||
`getTenants()` returns every tenant (`Team::all()`), or `canAccessPanel()`
|
||
returns `true` for everyone on a panel with open registration. **Safe when**
|
||
each gates on real membership. A permissive `getTenants()` alone (with
|
||
`canAccessTenant()` still gated) only discloses tenant names in the switcher —
|
||
verify and note in the §2 Issue paragraph; access is re-checked at
|
||
identification.
|
||
- **Fix**: gate on membership — `canAccessTenant`:
|
||
`return $this->teams()->whereKey($tenant)->exists();`; `getTenants`:
|
||
`return $this->teams;`; `canAccessPanel`: a role/flag/email-domain check.
|
||
- **Why**: these methods are the front door to a tenant. The impact is latent
|
||
while the panel has no tenant-scoped resources, but becomes a direct
|
||
cross-tenant hole the moment one is added — flag it regardless.
|
||
- **Docs**: https://filamentphp.com/docs/5.x/users/tenancy#tenancy-security
|
||
|
||
### D5. `unique` / `exists` validation ignores the tenancy scope — `[Site]` `[Conditional]`
|
||
|
||
- **Search**: in tenant-enabled panels (`$isScopedToTenant !== false`),
|
||
`grep -rnE "->(unique|exists)\(" app/Filament` on resource form fields; also
|
||
`grep -rnE "['\"](unique|exists):" app/Filament` to catch the string-rule
|
||
forms (`->rules(['unique:...'])`, `Rule::unique(...)`), which bypass scopes
|
||
identically.
|
||
- **Flag if**: a tenant-scoped resource uses `unique()` / `exists()` validation.
|
||
- **Fix**: use `->scopedUnique()` / `->scopedExists()` (no-arg defaults to the
|
||
component's model and field name; pass `model:` / `column:` only for a
|
||
non-default table).
|
||
- **Why**: Laravel's `unique`/`exists` query the DB directly without global
|
||
scopes, so cross-tenant data influences validation. Unscoped `exists` enables
|
||
cross-tenant reference binding; unscoped `unique` surfaces as false collisions
|
||
/ "value taken elsewhere" disclosure.
|
||
- **Docs**:
|
||
https://filamentphp.com/docs/5.x/users/tenancy#unique-and-exists-validation
|
||
|
||
## E. Dependencies
|
||
|
||
### E1. Known vulnerabilities in Filament / Livewire / Filament plugins — `[Site]`
|
||
|
||
- **Search**: `composer audit --format=plain` from the project root. Cross-check
|
||
package names against `composer.json` to identify which advisories affect
|
||
Filament (`filament/*`), Livewire (`livewire/livewire`), or installed Filament
|
||
plugins (any package whose name or description references Filament). Unrelated
|
||
framework / library CVEs are out of scope for this audit.
|
||
- **Flag if**: `composer audit` reports an in-scope advisory with a fixed
|
||
version available. One §2 entry per advisory.
|
||
- **Safe when**: no in-scope advisories, or the installed version already meets
|
||
the advisory's fixed range.
|
||
- **Fix**: `composer update <package> --with-all-dependencies` and re-run
|
||
`composer audit` to confirm the advisory is gone. If the fixed version is a
|
||
major bump, link the package's upgrade guide in the §2 entry rather than
|
||
pasting the command alone.
|
||
- **Why**: Filament and Livewire CVEs typically affect every panel using the
|
||
vulnerable version — usage-level mitigations don't apply. Outdated-but-
|
||
unaffected packages are not a finding (this is not a "stay current" check).
|
||
- **Docs**: https://getcomposer.org/doc/03-cli.md#audit
|