Weave Code
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

Plugin Laravel Laravel Package

psalm/plugin-laravel

Laravel Psalm plugin for deep static analysis plus taint-based security scanning. Detects SQL injection, XSS, SSRF, shell injection, path traversal, and open redirects by tracking user input through Laravel code without running it.

View on GitHub
Deep Wiki
Context7

title: Taint Analysis Stubs

Taint Analysis Stubs

This guide covers how to write and review taint analysis stubs for psalm-plugin-laravel.

For Psalm's upstream taint analysis documentation, see:

  • Security Analysis overview: how taint sources, sinks, and types work
  • Taint annotations reference: [@psalm-taint-source](https://github.com/psalm-taint-source), [@psalm-taint-sink](https://github.com/psalm-taint-sink), [@psalm-taint-escape](https://github.com/psalm-taint-escape), [@psalm-taint-unescape](https://github.com/psalm-taint-unescape), [@psalm-taint-specialize](https://github.com/psalm-taint-specialize), [@psalm-flow](https://github.com/psalm-flow)
  • Avoiding false positives: [@psalm-taint-escape](https://github.com/psalm-taint-escape), [@psalm-taint-specialize](https://github.com/psalm-taint-specialize), ignoring files
  • Avoiding false negatives: [@psalm-taint-unescape](https://github.com/psalm-taint-unescape)
  • Custom taint sources: [@psalm-taint-source](https://github.com/psalm-taint-source) annotation and plugin API
  • Custom taint sinks: [@psalm-taint-sink](https://github.com/psalm-taint-sink) annotation
  • Taint flow: [@psalm-flow](https://github.com/psalm-flow) proxy and return hints

Stub location

Taint annotations live in stubs/common/ alongside type stubs, organized by Laravel namespace. Taint analysis is opt-in (runTaintAnalysis="true" in psalm.xml, or --taint-analysis CLI flag), so there is no need for a separate directory. The stubs apply whenever taint analysis is enabled.

Annotations quick reference

There are six taint-related annotations. The first four are the ones you'll use most in stubs:

Annotation Purpose Needs [@psalm-flow](https://github.com/psalm-flow)?
[@psalm-taint-source](https://github.com/psalm-taint-source) <kind> Marks return value as producing tainted data No. Sources create new taint.
[@psalm-taint-sink](https://github.com/psalm-taint-sink) <kind> <$param> Marks a parameter as dangerous if tainted No. Sinks are endpoints.
[@psalm-taint-escape](https://github.com/psalm-taint-escape) <kind> Removes a specific taint kind from the return value Yes. See critical rule below.
[@psalm-flow](https://github.com/psalm-flow) (<$params>) -> return Declares that taint propagates from params to return N/A (this IS the flow declaration)
[@psalm-taint-unescape](https://github.com/psalm-taint-unescape) <kind> Re-adds a taint kind (reverses an earlier escape) Yes (same pattern as escape)
[@psalm-taint-specialize](https://github.com/psalm-taint-specialize) Tracks taints per call-site instead of globally No

Critical rule: always pair [@psalm-taint-escape](https://github.com/psalm-taint-escape) with [@psalm-flow](https://github.com/psalm-flow)

[@psalm-taint-escape](https://github.com/psalm-taint-escape) alone makes the return value fully untainted. It drops ALL taint kinds, not just the one specified. This creates dangerous false negatives.

To remove only specific taint kinds while preserving others, you must add [@psalm-flow](https://github.com/psalm-flow):

// WRONG: drops ALL taints (html, sql, shell, etc.)
// e($userInput) used in a SQL query would NOT trigger TaintedSql
/**
 * [@psalm-taint-escape](https://github.com/psalm-taint-escape) html
 * [@psalm-taint-escape](https://github.com/psalm-taint-escape) has_quotes
 */
function e($value, $doubleEncode = true) {}

// CORRECT: drops only html + has_quotes, other taints flow through
// e($userInput) used in a SQL query WILL trigger TaintedSql
/**
 * [@psalm-taint-escape](https://github.com/psalm-taint-escape) html
 * [@psalm-taint-escape](https://github.com/psalm-taint-escape) has_quotes
 * [@psalm-flow](https://github.com/psalm-flow) ($value) -> return
 */
function e($value, $doubleEncode = true) {}

The same rule applies to [@psalm-taint-unescape](https://github.com/psalm-taint-unescape): always pair it with [@psalm-flow](https://github.com/psalm-flow).

Psalm's own stubs follow this pattern (see urlencode()/strip_tags() in vendor/vimeo/psalm/stubs/CoreGenericFunctions.phpstub).

When [@psalm-flow](https://github.com/psalm-flow) is NOT needed

Sinks don't need [@psalm-flow](https://github.com/psalm-flow) because they are endpoints: they consume tainted data, they don't produce output.

/**
 * [@psalm-taint-sink](https://github.com/psalm-taint-sink) sql $query
 */
public function unprepared($query) {}

Sources don't need [@psalm-flow](https://github.com/psalm-flow) because they create new taint on the return value, not flow from input:

/**
 * [@psalm-taint-source](https://github.com/psalm-taint-source) input
 */
public function input($key = null, $default = null) {}

Exception (sink-only escapes): If a function's return value is never used for taint-sensitive operations (e.g., Hash::make() returns a hash that's safe by nature), [@psalm-taint-escape](https://github.com/psalm-taint-escape) without [@psalm-flow](https://github.com/psalm-flow) is acceptable because there's no meaningful taint to preserve on the return value.

Taint kinds

Most taint kind names are defined in Psalm\Type\TaintKind::TAINT_NAMES. Psalm's docblock parser also accepts arbitrary strings as taint kinds: anything not in that constant flows through TaintedCustom and reports as Detected tainted <kind>. The plugin uses this to model html_url (see URL context vs HTML escaping).

Common kinds used in stubs

Kind Attack vector Example sink Example escape
html XSS via HTML injection echo, Response::make() e(), htmlspecialchars()
has_quotes Attribute injection via unquoted strings echo inside HTML attributes e(), urlencode()
html_url XSS via URL-scheme injection in <a href> / <img src> (e.g. javascript:, data:) Notifications\Messages\MailMessage::action($url) App-defined URL allowlister (e.g. Str::sanitizeUrl()); NOT e()
sql SQL injection Connection::unprepared() Connection::escape(), parameterized queries
shell Command injection Process::run() escapeshellarg()
ssrf Server-side request forgery Http::get($url) N/A
file Path traversal Filesystem::get(), response()->download() N/A
user_secret Password/token exposure in logs or output echo, log sinks, md5(), sha1() Hash::make(), Encrypter::encrypt()
system_secret Internal secret exposure echo, log sinks, md5(), sha1() Hash::make(), Encrypter::encrypt()

All available kinds

Kind Constant Description
callable INPUT_CALLABLE User-controlled callable strings
unserialize INPUT_UNSERIALIZE Strings passed to unserialize()
include INPUT_INCLUDE Paths passed to include/require
eval INPUT_EVAL Strings passed to eval()
ldap INPUT_LDAP LDAP DN or filter strings
sql INPUT_SQL SQL query strings
html INPUT_HTML Strings that could contain HTML/JS
has_quotes INPUT_HAS_QUOTES Strings with unescaped quotes
shell INPUT_SHELL Shell command strings
ssrf INPUT_SSRF URLs passed to HTTP clients
file INPUT_FILE Filesystem paths
cookie INPUT_COOKIE HTTP cookie values
header INPUT_HEADER HTTP header values
xpath INPUT_XPATH XPath query strings
sleep INPUT_SLEEP Values passed to sleep() (DoS)
extract INPUT_EXTRACT Values passed to extract()
user_secret USER_SECRET User-supplied secrets (passwords, tokens)
system_secret SYSTEM_SECRET System secrets (API keys, encryption keys)
input ALL_INPUT Alias: all input-related kinds combined (excludes secrets)
tainted ALL_INPUT Alias: same as input
input_except_sleep ALL_INPUT & ~INPUT_SLEEP All input kinds except sleep (used by filter_var())
html_url (custom, plugin-defined) URL emitted into an HTML attribute (href, src, …). Distinct from html because HTML-escaping (e()) blocks attribute breakout but NOT scheme injection (javascript:, data:). Distinct from ssrf because the threat is client-side XSS, not server-side request forgery. NOT a member of the input alias: must be sourced explicitly.

URL context vs HTML escaping (html_url)

e() (and htmlspecialchars()) escapes HTML special characters. That blocks attribute-breakout XSS like "><script>alert(1)</script>. It does NOT validate the URL scheme, so a value emitted into <a href="{{ $url }}"> or <img src="{{ $url }}"> can still execute as javascript:alert(1) or data:text/html,.... Filament shipped a stored-XSS fix for exactly this pattern (GHSA-3fc8-8hp6-6jr4), adding a separate Str::sanitizeUrl() helper that allowlists http / https / mailto / tel schemes and applying it across every URL-attribute renderer (<a href>, <img src>, and friends). Laravel's MailMessage::action($url) lands in the same <a href="…"> shape via the notification email template, which is why the new sink targets it.

html_url models this cleanser-context distinction:

  • e() escapes html and has_quotes only (see stubs/common/Support/helpers.phpstub). It does NOT escape html_url, so an html_url-tainted value that flows through e() is still flagged at an html_url sink.
  • Notifications\Messages\MailMessage::action($url) is annotated with both [@psalm-taint-sink](https://github.com/psalm-taint-sink) html and [@psalm-taint-sink](https://github.com/psalm-taint-sink) html_url. The first catches body-content XSS (the URL is concatenated into HTML); the second catches scheme-injection inside the <a href="…"> attribute.

Detection gap: html_url is opt-in at the source

html_url is NOT a member of TaintKindGroup::ALL_INPUT. That means generic Laravel input sources ($request->input(…), $request->query(…), model attributes) do NOT auto-flow as html_url. The canonical Filament flow (form input → DB → Blade {{ $url }}<img src>) will NOT be caught out of the box. You must mark the value at a boundary you trust:

final class StoreAvatarRequest extends FormRequest
{
    public function rules(): array
    {
        return ['avatar_url' => ['required', 'url']];
    }

    /**
     * [@psalm-taint-source](https://github.com/psalm-taint-source) html_url
     */
    public function avatarUrl(): string
    {
        return (string) $this->input('avatar_url');
    }
}

Anywhere this accessor is used and the value reaches an html_url sink without passing through an html_url escape, the plugin flags TaintedCustom: Detected tainted html_url.

Annotating an app-level URL sanitizer

Laravel core ships Str::isUrl($value, ['http', 'https']) as a scheme-allowlisting validator (returns bool), but no first-party sanitizer that returns a cleaned string. To use Str::isUrl() as an html_url escape, wrap it in an app helper that returns the URL on true and a safe fallback (e.g. '#') on false, then annotate the wrapper. If your app defines its own sanitizer (a Str::macro('sanitizeUrl', …), an HtmlUrl value object, a dedicated helper), annotate that instead:

/**
 * Allowlists http/https/mailto/tel; returns '#' for anything else.
 *
 * [@psalm-taint-escape](https://github.com/psalm-taint-escape) html_url
 * [@psalm-flow](https://github.com/psalm-flow) ($url) -> return
 */
function safe_url(string $url): string
{
    return preg_match('#^(https?|mailto|tel):#i', $url) === 1 ? $url : '#';
}

The [@psalm-flow](https://github.com/psalm-flow) line is mandatory. Without it [@psalm-taint-escape](https://github.com/psalm-taint-escape) drops every taint kind on the return value, including html, so a value that was tainted for both kinds would silently appear clean (see Critical rule: always pair [@psalm-taint-escape](https://github.com/psalm-taint-escape) with [@psalm-flow](https://github.com/psalm-flow)). The regression test tests/Type/tests/TaintAnalysis/TaintedHtmlSanitizeUrlPreservesHtmlTaint.phpt exercises this exact mutation.

A value passed only through e() (which escapes html and has_quotes) is still tainted for html_url; a value passed only through safe_url() (which escapes html_url) is still tainted for html and has_quotes. The two cleansers are not interchangeable. Test coverage for this contract lives in tests/Type/tests/TaintAnalysis/TaintedHtmlUrl*.phpt and SafeHtmlUrl*.phpt.

Testing-time pitfall: Psalm's per-sink-node taint de-duplication

When two PHPT tests in the same suite source the same taint kind into the same stubbed sink (e.g. both flow html_url into MailMessage::action()), only one of the two will emit TaintedCustom. TaintFlowGraph::connectSinksAndSources() keeps a visited_source_ids[$sink_node][$taint_mask] set and skips repeated visits, so the first (sink, mask) pair reached during BFS wins the report and any subsequent source path to that same pair is silently dropped. The Tainted case using e() (TaintedHtmlUrlEDoesNotEscape.phpt) therefore routes through a per-file local sink instead of MailMessage::action(). The Safe test is unaffected: the sanitizer drops html_url, so the taint mask reaching the shared sink is 0, which is a distinct dedupe key from any concurrent Tainted test's html_url mask. Use a local [@psalm-taint-sink](https://github.com/psalm-taint-sink) html_url $url helper whenever you need a second Tainted test against an already-covered sink.

Stub patterns by annotation type

Source stubs

Mark methods that return user-controlled data. In Laravel, the primary sources are on Request:

/**
 * [@psalm-taint-source](https://github.com/psalm-taint-source) input
 */
public function input($key = null, $default = null) {}

Sink stubs

Mark parameters where tainted data is dangerous. Always specify which parameter receives tainted data:

/**
 * [@psalm-taint-sink](https://github.com/psalm-taint-sink) sql $query
 */
public function select($query, $bindings = [], $useReadPdo = true) {}

Multiple parameters can be sinks:

/**
 * [@psalm-taint-sink](https://github.com/psalm-taint-sink) html $callback
 * [@psalm-taint-sink](https://github.com/psalm-taint-sink) html $data
 */
public function jsonp($callback, $data = []) {}

Unsafe reflection (CWE-470) — container resolution

A user-controlled class name resolved through the container lets an attacker instantiate arbitrary classes (constructor side effects, gadget chains). The container entry points reuse the built-in callable kind, the same kind Psalm applies to new $var() and dynamic invocation:

  • app($abstract) / resolve($name)stubs/common/Foundation/helpers.phpstub
  • Container::make($abstract) / Container::makeWith($abstract)stubs/common/Container/Container.phpstub
/**
 * [@psalm-taint-sink](https://github.com/psalm-taint-sink) callable $abstract
 */
public function make($abstract, array $parameters = []) {}

The helper stubs (app, resolve) carry the sink only; their return type is still produced by ContainerHandler. The bare new $var(), $callback(), and call_user_func() forms in the issue are already caught by Psalm core's callable sink combined with the plugin's Request taint sources, so no stub is needed for those.

The App::make(...) facade form does not propagate taint — see Known limitation: Facade static calls. Use the app() / resolve() helpers or an instance typed as Illuminate\Container\Container for analyzable code.

Escape stubs (with flow)

Mark functions that sanitize specific taint kinds. Always pair with [@psalm-flow](https://github.com/psalm-flow):

/**
 * [@psalm-taint-escape](https://github.com/psalm-taint-escape) html
 * [@psalm-taint-escape](https://github.com/psalm-taint-escape) has_quotes
 * [@psalm-flow](https://github.com/psalm-flow) ($value) -> return
 */
function e($value, $doubleEncode = true) {}

Unescape stubs (with flow)

Mark functions that reverse sanitization, re-introducing taint. Used for decrypt, decode, etc.:

/**
 * [@psalm-taint-unescape](https://github.com/psalm-taint-unescape) user_secret
 * [@psalm-taint-unescape](https://github.com/psalm-taint-unescape) system_secret
 * [@psalm-flow](https://github.com/psalm-flow) ($payload) -> return
 */
public function decrypt($payload, $unserialize = true) {}

Flow-only stubs

When a function passes taint through without escaping or sinking, use [@psalm-flow](https://github.com/psalm-flow) alone. This is useful for wrapper functions where Psalm can't automatically trace the data flow:

/**
 * [@psalm-flow](https://github.com/psalm-flow) ($value, $items) -> return
 */
function inputOutputHandler(string $value, string ...$items): string {}

PDO parameterized queries

Eloquent and the Query Builder use PDO prepared statements for WHERE conditions, HAVING clauses, and primary-key lookups. When a value is passed to where('col', $value), Laravel stores it in $this->bindings['where'][] via addBinding() and the grammar compiles it as a ? placeholder. The value never enters the SQL string. PDO binds it at execution time, making SQL injection impossible regardless of content.

This creates two distinct annotation responsibilities:

  • Column names ($column): interpolated literally into the SQL identifier (e.g., WHERE {$column} = ?), so user-controlled column names are a real injection risk. Mark with [@psalm-taint-sink](https://github.com/psalm-taint-sink) sql $column.
  • Values ($value, $operator in 2-arg form, $id): PDO-bound, never interpolated. Use [@psalm-taint-escape](https://github.com/psalm-taint-escape) sql to suppress false-positive TaintedSql warnings, paired with [@psalm-flow](https://github.com/psalm-flow) to preserve other taint kinds.

Pattern for where-family methods

/**
 * [@psalm-taint-sink](https://github.com/psalm-taint-sink) sql $column           -- column names go into SQL identifiers; warn if tainted
 * [@psalm-taint-escape](https://github.com/psalm-taint-escape) sql                 -- values are PDO-bound; strip sql taint from return value
 * [@psalm-flow](https://github.com/psalm-flow) ($operator, $value) -> return  -- preserve other taint kinds (html, shell, etc.)
 */
public function where($column, $operator = null, $value = null, $boolean = 'and') {}

Both $operator and $value appear in [@psalm-flow](https://github.com/psalm-flow) because in the 2-argument form (where('col', $userValue)), Laravel's prepareValueAndOperator() moves the second argument into the $value position (the original $value = null is discarded), so user input may arrive via $operator at the call site, even though it is always PDO-bound.

The same pattern applies to orWhere(), whereNot(), orWhereNot(), having(), and orHaving().

Pattern for find-family methods

/**
 * [@psalm-taint-escape](https://github.com/psalm-taint-escape) sql       -- id is PDO-bound; strip sql taint from return value
 * [@psalm-flow](https://github.com/psalm-flow) ($id) -> return   -- preserve other taint kinds
 * [@psalm-taint-specialize](https://github.com/psalm-taint-specialize)       -- track taint per call-site (see note below)
 */
public function find($id, $columns = ['*']) {}

[@psalm-taint-specialize](https://github.com/psalm-taint-specialize) is required here. Without it, a single find($taintedId) call anywhere in the codebase would mark ALL find() return values as tainted globally (including find(1) with a safe literal). See Flow-through factories need [@psalm-taint-specialize](https://github.com/psalm-taint-specialize) for the general rule.

This specialize + escape pattern applies to find(), findMany(), findOrFail(), findOrNew(), and findSole().

firstWhere() is a hybrid: it also accepts a $column argument that is interpolated into SQL, so it additionally needs [@psalm-taint-sink](https://github.com/psalm-taint-sink) sql $column and [@psalm-flow](https://github.com/psalm-flow) ($operator, $value). Do not treat it as a pure find-family method.

Note that where() does NOT need [@psalm-taint-specialize](https://github.com/psalm-taint-specialize) because it returns $this (the fluent builder), a value that is chained further rather than consumed at the call site. Per-call-site isolation matters for concrete return values (models, scalars), not for method-chaining builders.

Raw methods must not get the escape

Raw SQL methods accept a string that is interpolated verbatim into the query with no parameterization:

/**
 * [@psalm-taint-sink](https://github.com/psalm-taint-sink) sql $sql   -- raw SQL goes directly into the query string
 */
public function whereRaw($sql, $bindings = [], $boolean = 'and') {}

Never add [@psalm-taint-escape](https://github.com/psalm-taint-escape) sql to whereRaw(), orWhereRaw(), selectRaw(), havingRaw(), orderByRaw(), groupByRaw(), fromRaw(), DB::statement(), or DB::unprepared().

Known limitations of [@psalm-flow](https://github.com/psalm-flow)

$this is...

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.
codraw/entity-migrator
codraw/doctrine-extra
codraw/aws-tool-kit
codraw/validator
codraw/workflow
codraw/open-api
codraw/cron-job
codraw/process
codraw/log
nexmo/api-specification
capell-app/block-library
axium/identity
cetria/laravel-dummy-models
cetria/reflection-helper
agropredict/sso-auth-bundle
evolvestudio/spam-protection
datacore/hub-sdk
develia/commons
cuci/prototurk-sdk
cuci/prototurk-sdk-symfony