taint-analysis
Skill5.9k starsChanged yesterdayMITView on GitHub
Use when changing or debugging Psalm's taint analysis (TaintFlowGraph, TaintFlowResolution, TaintKind, taint sinks/sources/escapes, InternalTaintSinkMap), writing TaintTest cases, reproducing a taint false positive or negative, or reviewing a taint PR. Covers repro modes, test-harness pitfalls, graph invariants, resolver limits and hang debugging.
We split this skill into the rules it gives an agent
4 rules. About 0 are common, 1 is distinctive, and 3 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
3
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 101
Only @psalm-suppress TaintedInput (it cuts the graph in that scope) and config <issueHandlers> work.
- Very specific: line 126
Don't write tests for sinks of extensions CI's setup-php doesn't load (e.g.
- Very specific: line 128
Use int keys for spreads (...$a) in TaintTest: string-key unpacking is a DuplicateArrayKey error at PHP 8.0.
- Distinctive: line 160
After fixing a hang, expect self-analysis errors that accumulated unseen while Shepherd was red.
≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Taint analysis
Where things live
src/Psalm/Type/TaintKind.php: taint bits. Adding a builtin bit shiftsUSER_SECRET/SYSTEM_SECRET/ALL_INPUT/BUILTIN_TAINT_COUNT/TAINT_NAMESand theCodebase::addTaintSource/addTaintSinkdefaults. The BC check reports these, but an addition is not a BC break: fix them in.roave-backward-compatibility-check.xml(skillplugin-api-bc).src/Psalm/Internal/Codebase/TaintFlowGraph.php: graph building,addPath(),connectSinksAndSources(),reportTaintedFlow()and report dedupe.src/Psalm/Internal/Codebase/TaintFlowResolution.php: the resolver (specialized-call entries, open assignments, convergence, filters, widening). Hot code: see the hot-loop style rule in CLAUDE.md.src/Psalm/Internal/Codebase/CombinedFlowGraph.php: forwards to the taint graph and the variable-use graph (VUG).dictionaries/InternalTaintSinkMap.php: sinks for builtin functions and methods ('Closure::fromCallable' => [TaintKind::INPUT_CALLABLE],'mysqli::query' => ...).ArgumentsAnalyzer::getArgumentSinks()/addCurlOptionArraySinks(): per-argument sinks that depend on another argument's value (curl options).CastAnalyzer::stripCastTaints(): scalar casts route parents through a<type>-castnode whose edge carriesremoved_taints.- Providers:
tests/TaintTest.php(providerValidCodeParse/providerInvalidCodeParse,IGNORE, dedicated test methods); they run sharded throughtests/Taint/TaintCases{1..8}Test.php+TaintCasesTestCase.php, each case in both taint-only mode and tracking unused variables.
Invariants: don't break these
addPath()replaces an existing edge between the same nodes, and that is load-bearing.FunctionCallReturnTypeFetcher::taintUsingFlows()re-adds thefn#1 -> fnarg edge to replace it with the escaped taints; makingaddPathmerge re-adds brokefilter_var(..., FILTER_SANITIZE_NUMBER_INT). For "last call decides for all callers" bugs on a shared edge, put the per-call taints on a node keyed by the call location (DataFlowNode::getForAssignment($id, $node_location)), like the-escapednode of conditional escapes.- A node that only sits on a path still needs
$graph->addNode(); otherwise the flow through it is silently dropped. - Mode parity: types get the same parent nodes, and the taint graph the same edges,
whether or not unused variables are tracked. Write everything through
data_flow_graph; never write tovariable_use_graphdirectly (only query it). Paths no taint goes through useremoved_taints = TaintKind::ALL, whichTaintFlowGraphdrops. Taint-only paths (addPathsFromNestedParentNodes) stay out of the VUG: VUG edges driveComplexMethodin self-analysis (see CLAUDE.md). - Speculative per-call specialization nodes must not reach the VUG (they change its edge
stats). Specialization of inferred-pure callees is resolved inside
connectSinksAndSources(), because the test harness calls it directly, beforeMutationLevelResolver::resolve().
Accepted tradeoffs: don't report these as bugs
- Resolver limits are a RAM vs precision compromise (
WIDENING_STATES = 16,MAX_CONVERGENCE_KEYS = 8,MAX_FILTERS = 64inTaintFlowResolution). A false positive caused by a lower cap is accepted. Do review how a limit is applied: uneven budget spending, order-dependent results, soundness gaps at the fallback, and whether a known-false-positive test documents the compromise. In the other direction, a real false-negative fix may justify a moderate RAM increase: measure it (main-process "used N MB" of self-analysis), report it, and ask when it's large. - Stale remembered types: taint decisions that trust inferred types (e.g.
Union::getTaintsToRemove()at argument boundaries) inherit Psalm's stale types. With the defaultrememberPropertyAssignmentsAfterCall="true", a property/static/superglobal overwritten by a call keeps its old type. That is a config tradeoff, not a bug. Real type-tracking holes (non-literal key writes leaving per-key entries, superglobals not forgotten) are bugs. When a change strips taint or skips a sink by type, probe non-literal key writes, prop/global-writing calls and references against the base.
Reproducing
- For an issue that a provider case covers (or a new case you are adding), prefer running
that one case:
vendor/bin/phpunit tests/Taint --filter '@caseName'runs it in both modes and avoids every CLI-vs-harness difference below. Never run whole test files.
From the CLI
- A scratch
./psalmrun builds a taint graph even withoutrunTaintAnalysis.- Taint-only:
findUnusedCode="false" findUnusedVariablesAndParams="false"plus--taint-analysis. - Combined mode:
findUnusedVariablesAndParams="true". - Provider cases marked
// --taint-analysisneed the--taint-analysisflag.
- Taint-only:
- With
--output-format=compact, taint lines look likeSECURITY src/a.php:5:1 TaintedShell: ..., so a grep for^ERRORmisses them. Grep both, and compare non-taint errors againstTaintTest::IGNORE: an invalid provider case throws on the first issue not in IGNORE (e.g.MissingClosureParamTypefrom an untypedfn($s) => $s). - TaintTest analyses PHP 8.0 by default; local repros at errorLevel 8 hide issues the
harness reports (e.g.
PossiblyUndefinedArrayOffset). - Unwanted specialization: project functions and private/final methods whose purity
infers as pure get specialized per call, and
error_log()doesn't prevent it. To test shared-node behaviour, use@psalm-impure, or a non-final public method or static method writing a static property. PDO::queryisn't a sink in a bare scratch project (extension not loaded). Useshell_execfor a non-html sink.- Two
@psalm-taint-escape (cond ? ...)annotations on one function make every call remove both kinds, whatever the arguments. @psalm-suppress TaintedHtml(or any specificTainted*) in a docblock does nothing. Only@psalm-suppress TaintedInput(it cuts the graph in that scope) and config<issueHandlers>work.- Under
GITHUB_WORKFLOWwith generated reports,IssueBuffer::finish()skips itsexit(2)when taint analysis ran. End-to-end tests asserting exit 2 setrunTaintAnalysis="false". - To compare behaviour across many provider cases (only when explicitly asked: sweeps
count as local test runs), load providers via
(new ReflectionClass(TaintTest::class))->newInstanceWithoutConstructor()->providerInvalidCodeParse(), write one scratch project per case and mode, run withxargs -P 8, and diff against a master worktree. About 27 invalid cases never match from the CLI because of harness differences.
Writing tests
- An invalid provider case and
DocumentationTestonly see the first issue. Which kind comes first when one sink reports several depends on the graph. Whenever a change gives a sink more kinds, grep every provider case and docs example reaching it (DocumentationTesthas per-issue$ignored_issues, e.g. TaintedSSRF ignores TaintedFile). Assert multi-kind results in a dedicated method withthrow_exception = false+IssueBuffer::getIssuesDataForFile()(seetestCurlSinks). TaintCasesTestCase::testInvalidCodenever callsinitializePlugins(), so removals by internal AddRemoveTaints handlers (JsonEncodeTainter,HtmlFunctionTainter) don't happen in provider cases: a regression test there passes on unfixed code. Use a dedicatedTaintTestmethod callinginitializePlugins()with an exact issue list. It defaults to PHP < 8, so callsetPhpVersion('8.0', 'tests')for union types.- Don't write tests for sinks of extensions CI's setup-php doesn't load (e.g.
XMLReader):UndefinedClassis in IGNORE, so they pass with no sink at all. - Use int keys for spreads (
...$a) in TaintTest: string-key unpacking is aDuplicateArrayKeyerror at PHP 8.0. - Stub repros: a stub never overrides a class declared in project dirs. Put the stubbed
library in
vendor/+<extraFiles>, not in<projectFiles>.
Adding sinks
- A builtin that isn't stubbed (e.g.
Closure) takes params straight from the call map, so sinks on reflectedMethodStorageare never seen: useInternalTaintSinkMap. stubs/extensions/*.phpstubload only when the extension is enabled (composerext-*or<enableExtensions>), so sinks for simplexml/dom/soap/… must also go inInternalTaintSinkMap.- URL-fetching sinks get
file|ssrf|url_component|url_path; host-only sinks (fsockopen) getssrfonly. - Report dedupe is per (sink, predecessor, origin): SSRF hides url_component and url_path, and url_component hides url_path. When one node fans out to per-kind nodes, link one node per single kind, never per mask, or a shared kind is reported twice.
- Verified sink facts (curl options, URL kinds, casts): see
references/sink-facts.md.
Debugging resolver hangs or blowups
- Instrument the resolver per round: queue size,
count(state_nodes), top state ids per node, per-walk time, exits per entry, filters per base entry. The hub node usually stands out (historicallyContext::$vars_in_scope,Union::__constructand Union's small getters: everything Psalm builds from parsed files is tainted by$argv/file reads, so self-analysis reaches every Union method). - Ruled out before: dropping context at every property node (context sensitivity through
shared state is intended), widening only flows without open assignments, converging
shared state (17M states / 41 GB), and gating
leaveCalls()behind caller counts. Widening must also forget open assignments, or the first widened flow's assignments take the merged state (false negative). - Instrumentation patches kept in
/tmpdon't survive reboots; rebuild them. - After fixing a hang, expect self-analysis errors that accumulated unseen while Shepherd was red.
Known open false negatives (for context, not to re-discover)
- Converging flows with different open assignments through specialized functions:
return $b ? ["k" => $a] : $a;then$r["k"]["x"]. Keying the visited guard on open assignments doesn't terminate on wrapping loops. - Recursion deeper than the open-assignment window over-approximates (false positives only), by design.
Other versions1 project uses a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 1
- rainoftime/pl-skills★ 17same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository8 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- hack-conformanceSkill
- merge-6x-into-masterSkill
- plugin-api-bcSkill
- pr-descriptionSkill
- psalm-reproSkill
- purity-capabilitiesSkill
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

