Security (Taint) Checks
Security scanning runs automatically alongside type analysis, no extra configuration needed.
What it detects
| Vulnerability | OWASP | Examples |
|---|---|---|
| SQL Injection | A03:2021 | DB::statement(), DB::unprepared(), raw query methods |
| Shell Injection | A03:2021 | Process::run(), Process::command() |
| XSS | A03:2021 | Response::make() with unescaped content |
| XSS (Blade templates) | A03:2021 | Opt-in with <blade />: unescaped {!! $userInput !!} output, view data and class-component properties. See Blade coverage |
| Header Injection | A03:2021 | Response::make(), response(), and new Response() with user-controlled header values; mail sender/recipient addresses (Mailable from(), to(), cc(), bcc(), replyTo(); MailMessage from(), cc(), bcc(), replyTo()) |
| SSRF | A10:2021 | Http::get(), Http::post() with user-controlled URLs |
| File Traversal | A01:2021 | Storage::get(), File::delete() with user-controlled paths |
| Open Redirect | A01:2021 | redirect(), Redirect::to() with user-controlled URLs |
| Crypto misuse | A02:2021 | Tracks encryption/hashing taint escape and unescape |
| Timing attack | A02:2021 | Secret compared with ===, <=>, strcmp() (CWE-208) |
| Prompt injection | LLM01:2025 | laravel/ai prompt sinks: agent prompts, messages, media, documents, reranking, classification, and tool descriptions. See LLM integration |
| LLM output reuse | LLM01:2025 | laravel/ai model-output and tool-result sources: response text, reasoning, structured output, streamed deltas, transcripts, and classification answers |
UploadedFile::getClientOriginalExtension() is deliberately not a file source: Symfony’s File::getName() and UploadedFile::getClientOriginalExtension() yield a slash-, backslash-, and dot-free extension, which cannot form a traversal segment.
UploadedFile::clientExtension() is deliberately not a taint source. The client chooses the MIME lookup key, but Symfony returns a value from the application’s configured MIME registry. This avoids false positives for generated upload filenames; applications that populate that registry from untrusted data must model that boundary separately.
Artisan commands, migrations and seeders print to a terminal, not an HTML page, so their echo/print/printf()/var_dump() output is not reported as TaintedHtml or TaintedTextWithQuotes (Psalm 7’s @psalm-cli-only, applied to every subclass of Illuminate\Console\Command, Illuminate\Database\Migrations\Migration and Illuminate\Database\Seeder). Values stay tainted for every other sink, and printed secrets are still reported. Not covered: trait methods, closure commands in routes/console.php, and scheduled closures. Known gap: a command run through Artisan::call() during an HTTP request writes raw echo output into the response.
HTML and JSON escaping
json_encode() with literal HEX flags is treated as an escape. JSON_HEX_TAG with JSON_HEX_AMP clears the html taint (TaintedHtml, handled by Psalm itself): the output can no longer contain <, > or &. JSON_HEX_APOS, JSON_HEX_QUOT and JSON_HEX_AMP together clear the quoted-text taint (TaintedTextWithQuotes), because every ', " and & in the payload is encoded as \u0027 / \u0022 / \u0026. Any one missing keeps it: without JSON_HEX_QUOT a " becomes \" (a backslash escapes nothing in HTML), and without JSON_HEX_AMP an attribute decodes a payload " or < back to " or <. The Blade @json($value) directive defaults to all four HEX flags, so it reports nothing. Flags the analyzer cannot prove literal (a variable of unknown value, (int) $request->input(), a spread argument) and calls without these flags stay fully reported.
Known blind spot: json_encode()’s own " delimiters can break out of a double-quoted attribute or quoted JavaScript string, even when HEX flags escape the payload characters, so <div data-x="@json($value)"> is an unreported attribute breakout. Scalar test data can hide it: ints, bools and null encode bare and render fine. Single-quoted attributes (data-x='@json($value)') and <script> blocks are safe with these flags. @js($value) / Js::from($value) is a complete JavaScript expression (never wrap it in quotes) that fits a script or a double-quoted attribute.
HTTP response exceptions: ResponseFactory::make() and new Response()
ResponseFactory::make(), the response() helper’s direct 3-argument form, and the Illuminate\Http\Response constructor all report XSS for unescaped content because their default response is HTML. All three also sink their $headers argument as header: a tainted header value lets an attacker inject or override response headers (for example a Content-Disposition filename), independent of the content sink below. The attachment/content-type exemption applies only to the TaintedHtml content finding and never suppresses a genuinely tainted header value.
For ResponseFactory::make() (in its direct, contract, and facade forms) and the Illuminate\Http\Response constructor, the XSS finding is dropped for a positional call whose headers array proves either that the browser downloads the response instead of rendering it, or that the declared content type is never sniffed as HTML. It is dropped as it is reported, so no other flow through the same code is affected. The response() helper’s direct 3-argument form is not part of this exemption and always keeps its XSS finding.
The proof is deliberately syntactic, never control-flow aware. Two independent checks over the header entries; either alone is enough:
- Attachment disposition. A literal
Content-Disposition: attachmentvalue, or an interpolated or concatenated value whose literal leading part already contains theattachment;token: nothing after a literal parameter separator can retract it. Control characters other than a horizontal tab keep the finding, since a runtime that drops the header serves the response as HTML. Parameters after the token are not validated. - Safe content type. A literal, well-formed
Content-Typethat does not containhtml,xml(XML can carry an XHTML-namespaced<script>) orscript(the JavaScript media types), is notmultipart/*, and is not one of the sniffing escapesunknown/unknownandapplication/unknown. Every other well-formed type is exempt, vendor download types included; parameters after the first;are discarded.
The headers array may be written inline or held in a local variable assigned exactly once, as a plain statement before the call, and never reassigned, mutated, passed elsewhere, or captured. Anything the proof cannot see keeps the finding: $$x, the extract() family in the same scope, dynamic or duplicate or underscore-spelled headers (names are folded the way Symfony folds them), a malformed content type, and every named-argument call. The exemption applies to the concrete factory, its contract, the Illuminate\Support\Facades\Response facade (intersections included), and a direct new Illuminate\Http\Response(...). The root \Response alias and custom classes with a make() method keep the sink. A receiver typed as the factory or its contract is trusted to apply its headers argument; an implementation that silently discards it violates that contract and is out of scope.
One accepted gap. All make() calls in a project meet at a single taint graph node (and, separately, all new Response() calls meet at their own), and Psalm walks each node once, so it already reports only the shortest flow reaching it and discards the rest. When that shortest flow is the exempt one, the call reports nothing at all instead of reporting one of its flows. The longer flows are discarded whether or not the exemption applies, so this costs no coverage relative to running without the plugin.
Timing-safe comparisons (CWE-208)
Comparing a secret (a password hash, remember-token, or decrypted value) with a variable-time operator leaks it byte-by-byte to an attacker who can measure response time. The plugin flags secret-tainted values that flow into ===, ==, !==, !=, <=>, or the strcmp() / strcasecmp() / strncmp() / strncasecmp() / substr_compare() family. Use hash_equals() for a constant-time comparison instead.
$user->getAuthPassword() === $given; // flagged
hash_equals($user->getAuthPassword(), $given); // safe
Comparisons against a literal ($token === null, $key === '') are not flagged: the literal is the known half, so nothing about the secret leaks.
The finding is reported as TaintedUserSecret or TaintedSystemSecret, and the flagged location is the comparison itself. The message text is the generic Detected tainted user secret leaking rather than a CWE-208-specific one, because Psalm hardcodes taint messages per kind (vimeo/psalm#11762). Treat any such finding from this plugin as a timing issue and fix it with hash_equals().
LLM integration
Applies to projects using laravel/ai, which must satisfy >=1.0.0 <2.0.0; other versions, including 0.11.x, are unsupported: the integration stays disabled and the plugin contributes nothing (no stubs, handlers or issue policy). Projects without a supported package pay nothing. Prompt-injection and model-output findings are errors by default; findPromptInjection only controls the former.
Sources and sinks
- Prompt sinks. Untrusted input reaching a sink is reported as
TaintedLlmPrompt. SetfindPromptInjectiontofalseonly to suppress this issue; an explicit issue handler still wins, andtruecannot bypass the version gate. - Model-output sources. Model output reaching SQL, HTML, a shell command, and the like is reported as the ordinary
Tainted*issue with the ordinary fix (parameterize or escape).findPromptInjectiondoes not affect these. This models indirect prompt injection, where the payload arrives through a page, document, or tool result the model read.
Sink kinds (one example each):
- Prompting an agent.
$agent->prompt($input). - Ad-hoc agents, messages, and tools.
agent()instructions,new UserMessage($input). - Prompt mutation.
PendingStep::withInstructions()inside middleware. - Classification questions.
Classification::of($question). - Media and document factories.
Image::of($description). - Tool descriptions.
SimilaritySearch::withDescription().
Source kinds (one example each):
- Response text.
AgentResponse::$text, including its string cast. - Reasoning.
TextResponse::$reasoning. - Structured payloads.
StructuredAgentResponse::$structured. - Streamed deltas.
TextDelta::$delta. - Transcripts.
TranscriptionResponse::$text(and its string cast). The audio was supplied by a user, so the transcript is attacker-authored text a speech model merely re-typed. - Classification answers.
ClassificationResponse::answer(). - Tool results.
Contracts\Tool::handle()returns a source so tool output keeps flowing into later prompt sinks.
This is a summary. The authoritative, current lists are the taint annotations under stubs/integrations/laravel-ai/ and TAINTED_PROPERTIES in src/Handlers/Ai/LlmOutputTaintHandler.php.
Marking prompt-guard middleware as trusted
For middleware authors (a guard library, or an app with its own guard): use this when your middleware genuinely stops prompt injection before the prompt reaches the provider, so users need not suppress the finding by hand. Two phpdoc annotations:
- On the guard, annotate
handle()with@psalm-taint-escape llm_promptand@psalm-flow ($step) -> return.laravel/aicallshandle(PendingStep $step, Closure $next)once per generation step on each non-closure middleware entry (a class-string entry is resolved through the container first). - On the agent’s unchanged
middleware()method, declare a@returnnaming your guard class:@return list<PromptGuard>(@return list<class-string<PromptGuard>>works too). The agent must implementHasMiddleware(inherited counts); otherwiselaravel/ainever callsmiddleware().
What it does: TaintedLlmPrompt stops being reported for prompt() and stream() on that agent. Every other taint kind keeps flowing, so the same value reaching SQL, HTML, or a shell command is still reported.
The @psalm-flow line does not affect the exemption, which reads only the escape. Include it anyway: an escape with no @psalm-flow leaves the return value carrying no taint at all, not just no llm_prompt (see the pairing rule), so a direct caller of the guard would read a fully clean value when the body is not visible to Psalm.
Not exempted (the finding keeps reporting):
- Closure middleware. The guard lives in the closure body, which no declared type describes. Write the guard as a class.
- No
@returnonmiddleware(). A barearraynames nothing to check. - An escape anywhere but
handle().laravel/ainever calls__invoke()on a middleware object, so a guard whosehandle()is unannotated does not exempt, even if__invoke()carries the escape. The same applies to a guard class whose analysed subclass overrideshandle()without the annotation. - An agent whose
middleware()a subclass replaces. The call site only names the parent, and the subclass could run a different stack. Calling on the subclass directly is still exempt when it keeps the guarded stack. - Agents that remember conversations. This is intended, not a limit of the annotation. An agent remembers conversations when it uses the
Concerns\RemembersConversationstrait (anywhere up its parent chain), implementsContracts\RemembersConversations, or has an analysed subclass that adds either.laravel/aithen names each new conversation with a second model call carrying the first 500 characters of the raw prompt, and that call runs no agent middleware, so the guard never sees it. Settingai.conversations.generate_titletofalseremoves the second call, but the plugin cannot read that setting: suppress the finding explicitly once you have turned title generation off. queue()andbroadcast*(). They run the same pipeline but are not exempted yet.
The exemption is a declaration the plugin trusts, not proof that a payload is neutralised or that a guard blocks rather than logs. It is bounded by what the analysis can see: the middleware list comes from the declared @return type, not the method body (so an empty-array body still exempts, and a template bound such as class-string<T> names no guard and does not), and subclasses outside the analysed project (a library consumer’s own) or container bindings that swap a class-string entry are invisible. Mechanics: docs/contributing/taint-analysis.md.
Limitations
Each is an upstream limitation rather than a judgement that the flow is safe, so treat it as a blind spot when reviewing.
- Return-value sinks.
Tool::description()andAgent::instructions()are not covered: Psalm’s@psalm-taint-sinkmatches parameter names only. Tracked in #484. - Prompt macros. Prompt taint is not tracked through the
AiServiceProvidermacrosStr::summarize(),Stringable::summarize(),Stringable::toAudio(),Str::decide(),Stringable::decide(),Collection::decide()andCollection::rerank(): a macro is aMacroablepseudo-method with no per-method docblock on which to attach anllm_promptsink, soStr::summarize($request->input('d'))andcollect($items)->rerank('name', $query)report nothing even when the argument is tainted. Call the underlyingClassification::of(),Audio::of(),Reranking::of()(PendingReranking::rerank()is sunk when called directly) or an agent’sprompt()where the input is untrusted. - Citation collection reads.
StreamableAgentResponse::$citationsis a registered source, but reads through itsCollectionlose the taint, because Psalm drops the taint edge there. - Array-access reads.
$response['field']and$request['task']are not covered on any class: Psalm drops the taint edge when it resolves the[]sugar, which affects everyArrayAccess-based taint source and is left for an upstream fix (vimeo/psalm#11912). It is worth knowing about, becauseTools\AgentTooluses exactly that shape to pass a task to a sub-agent. PreferTools\Request::str()/string()/array(), or an explicitoffsetGet()call, all of which are covered.
Reading a single element back out of an array-typed source is covered. Both the $structured property and the toArray() return keep the taint through $payload['body'], so a sink reached that way reports like any other flow.
Other detection limits
Named arguments captured by a variadic. Two Psalm bugs (vimeo/psalm#12251, #12252) misattribute a named argument bound to a variadic parameter. The plugin drops the taint of such an argument when the callee resolves exactly to a concrete method or function, so a genuine sink in the variadic’s body or behind a re-spread (handle(...$args)) is not reported. Every other named-argument call keeps Psalm’s detection, with one gap in Psalm itself since 7.0.0-rc28 (vimeo/psalm#12760): when a parent’s variadic captures the argument (Base::go(string ...$rest) called as $base->go(sink: $x)), a sink on an override’s parameter of that name (Child::go(string $sink)) is not reported.
How it compares
| Tool | Laravel-aware types | Taint analysis | Free |
|---|---|---|---|
| psalm-laravel | Yes | Yes (dataflow) | Yes |
| Larastan | Yes | No (PHPStan can’t) | Yes |
| SonarQube | Generic PHP | Yes (generic) | Paid editions only |
| Semgrep | Pro tier only | Pattern-based | Limited free tier |
| Snyk Code | Generic | Yes (generic) | Freemium |